@likecoin/epubcheck-ts 0.1.0 → 0.2.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 +65 -10
- package/bin/epubcheck.js +170 -0
- package/bin/epubcheck.ts +227 -0
- package/dist/index.cjs +1756 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1756 -48
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
package/dist/index.cjs
CHANGED
|
@@ -4,8 +4,25 @@ var libxml2Wasm = require('libxml2-wasm');
|
|
|
4
4
|
var fflate = require('fflate');
|
|
5
5
|
|
|
6
6
|
// src/content/validator.ts
|
|
7
|
+
|
|
8
|
+
// src/references/types.ts
|
|
9
|
+
function isPublicationResourceReference(type) {
|
|
10
|
+
return [
|
|
11
|
+
"generic" /* GENERIC */,
|
|
12
|
+
"stylesheet" /* STYLESHEET */,
|
|
13
|
+
"font" /* FONT */,
|
|
14
|
+
"image" /* IMAGE */,
|
|
15
|
+
"audio" /* AUDIO */,
|
|
16
|
+
"video" /* VIDEO */,
|
|
17
|
+
"track" /* TRACK */,
|
|
18
|
+
"media-overlay" /* MEDIA_OVERLAY */
|
|
19
|
+
].includes(type);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/content/validator.ts
|
|
23
|
+
var DISCOURAGED_ELEMENTS = /* @__PURE__ */ new Set(["base", "embed"]);
|
|
7
24
|
var ContentValidator = class {
|
|
8
|
-
validate(context) {
|
|
25
|
+
validate(context, registry, refValidator) {
|
|
9
26
|
const packageDoc = context.packageDocument;
|
|
10
27
|
if (!packageDoc) {
|
|
11
28
|
return;
|
|
@@ -15,11 +32,22 @@ var ContentValidator = class {
|
|
|
15
32
|
for (const item of packageDoc.manifest) {
|
|
16
33
|
if (item.mediaType === "application/xhtml+xml") {
|
|
17
34
|
const fullPath = opfDir ? `${opfDir}/${item.href}` : item.href;
|
|
18
|
-
this.validateXHTMLDocument(context, fullPath, item.id);
|
|
35
|
+
this.validateXHTMLDocument(context, fullPath, item.id, opfDir, registry, refValidator);
|
|
36
|
+
} else if (item.mediaType === "text/css" && refValidator) {
|
|
37
|
+
const fullPath = opfDir ? `${opfDir}/${item.href}` : item.href;
|
|
38
|
+
this.validateCSSDocument(context, fullPath, opfDir, refValidator);
|
|
19
39
|
}
|
|
20
40
|
}
|
|
21
41
|
}
|
|
22
|
-
|
|
42
|
+
validateCSSDocument(context, path, opfDir, refValidator) {
|
|
43
|
+
const cssData = context.files.get(path);
|
|
44
|
+
if (!cssData) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const cssContent = new TextDecoder().decode(cssData);
|
|
48
|
+
this.extractCSSImports(path, cssContent, opfDir, refValidator);
|
|
49
|
+
}
|
|
50
|
+
validateXHTMLDocument(context, path, itemId, opfDir, registry, refValidator) {
|
|
23
51
|
const data = context.files.get(path);
|
|
24
52
|
if (!data) {
|
|
25
53
|
return;
|
|
@@ -35,12 +63,19 @@ var ContentValidator = class {
|
|
|
35
63
|
doc = libxml2Wasm.XmlDocument.fromString(content);
|
|
36
64
|
} catch (error) {
|
|
37
65
|
if (error instanceof Error) {
|
|
38
|
-
const message = this.
|
|
66
|
+
const { message, line, column } = this.parseLibxmlError(error.message);
|
|
67
|
+
const location = { path };
|
|
68
|
+
if (line !== void 0) {
|
|
69
|
+
location.line = line;
|
|
70
|
+
}
|
|
71
|
+
if (column !== void 0) {
|
|
72
|
+
location.column = column;
|
|
73
|
+
}
|
|
39
74
|
context.messages.push({
|
|
40
75
|
id: "HTM-004",
|
|
41
76
|
severity: "error",
|
|
42
77
|
message,
|
|
43
|
-
location
|
|
78
|
+
location
|
|
44
79
|
});
|
|
45
80
|
}
|
|
46
81
|
return;
|
|
@@ -84,33 +119,100 @@ var ContentValidator = class {
|
|
|
84
119
|
location: { path }
|
|
85
120
|
});
|
|
86
121
|
}
|
|
87
|
-
const
|
|
88
|
-
(item) => item.id === itemId
|
|
122
|
+
const manifestItem = packageDoc.manifest.find(
|
|
123
|
+
(item) => item.id === itemId
|
|
89
124
|
);
|
|
125
|
+
const isNavItem = manifestItem?.properties?.includes("nav");
|
|
90
126
|
if (isNavItem) {
|
|
91
127
|
this.checkNavDocument(context, path, doc, root);
|
|
92
128
|
}
|
|
129
|
+
if (context.version.startsWith("3")) {
|
|
130
|
+
const hasScripts = this.detectScripts(context, path, root);
|
|
131
|
+
if (hasScripts && !manifestItem?.properties?.includes("scripted")) {
|
|
132
|
+
context.messages.push({
|
|
133
|
+
id: "OPF-014",
|
|
134
|
+
severity: "error",
|
|
135
|
+
message: 'Content document contains scripts but manifest item is missing "scripted" property',
|
|
136
|
+
location: { path }
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const hasMathML = this.detectMathML(context, path, root);
|
|
140
|
+
if (hasMathML && !manifestItem?.properties?.includes("mathml")) {
|
|
141
|
+
context.messages.push({
|
|
142
|
+
id: "OPF-014",
|
|
143
|
+
severity: "error",
|
|
144
|
+
message: 'Content document contains MathML but manifest item is missing "mathml" property',
|
|
145
|
+
location: { path }
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const hasSVG = this.detectSVG(context, path, root);
|
|
149
|
+
if (hasSVG && !manifestItem?.properties?.includes("svg")) {
|
|
150
|
+
context.messages.push({
|
|
151
|
+
id: "OPF-014",
|
|
152
|
+
severity: "error",
|
|
153
|
+
message: 'Content document contains SVG but manifest item is missing "svg" property',
|
|
154
|
+
location: { path }
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const hasRemoteResources = this.detectRemoteResources(context, path, root);
|
|
158
|
+
if (hasRemoteResources && !manifestItem?.properties?.includes("remote-resources")) {
|
|
159
|
+
context.messages.push({
|
|
160
|
+
id: "OPF-014",
|
|
161
|
+
severity: "error",
|
|
162
|
+
message: 'Content document references remote resources but manifest item is missing "remote-resources" property',
|
|
163
|
+
location: { path }
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
this.checkDiscouragedElements(context, path, root);
|
|
168
|
+
this.checkAccessibility(context, path, root);
|
|
169
|
+
this.validateImages(context, path, root);
|
|
170
|
+
if (context.version.startsWith("3")) {
|
|
171
|
+
this.validateEpubTypes(context, path, root);
|
|
172
|
+
}
|
|
173
|
+
this.validateStylesheetLinks(context, path, root);
|
|
174
|
+
this.validateViewportMeta(context, path, root, manifestItem);
|
|
175
|
+
if (registry) {
|
|
176
|
+
this.extractAndRegisterIDs(path, root, registry);
|
|
177
|
+
}
|
|
178
|
+
if (refValidator && opfDir !== void 0) {
|
|
179
|
+
this.extractAndRegisterHyperlinks(path, root, opfDir, refValidator);
|
|
180
|
+
this.extractAndRegisterStylesheets(path, root, opfDir, refValidator);
|
|
181
|
+
this.extractAndRegisterImages(path, root, opfDir, refValidator);
|
|
182
|
+
}
|
|
93
183
|
} finally {
|
|
94
184
|
doc.dispose();
|
|
95
185
|
}
|
|
96
186
|
}
|
|
97
|
-
|
|
187
|
+
parseLibxmlError(error) {
|
|
188
|
+
const lineRegex = /(?:Entity:\s*)?line\s+(\d+):/;
|
|
189
|
+
const lineMatch = lineRegex.exec(error);
|
|
190
|
+
const line = lineMatch?.[1] ? Number.parseInt(lineMatch[1], 10) : void 0;
|
|
191
|
+
const columnRegex = /line\s+\d+:\s*(\d+):/;
|
|
192
|
+
const columnMatch = columnRegex.exec(error);
|
|
193
|
+
const column = columnMatch?.[1] ? Number.parseInt(columnMatch[1], 10) : void 0;
|
|
194
|
+
let message = error;
|
|
98
195
|
if (error.includes("Opening and ending tag mismatch")) {
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
196
|
+
message = `Mismatched closing tag: ${error.replace("Opening and ending tag mismatch: ", "")}`;
|
|
197
|
+
} else if (error.includes("mismatch")) {
|
|
198
|
+
message = `Mismatched closing tag: ${error}`;
|
|
199
|
+
} else {
|
|
200
|
+
message = error.replace(/^Entity:\s*line\s+\d+:\s*(parser\s+error\s*:)?\s*/, "");
|
|
103
201
|
}
|
|
104
|
-
return
|
|
202
|
+
return { message, line, column };
|
|
105
203
|
}
|
|
106
204
|
checkUnescapedAmpersands(context, path, content) {
|
|
107
|
-
const
|
|
108
|
-
|
|
205
|
+
const ampersandRegex = /&(?!(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);)/g;
|
|
206
|
+
let match;
|
|
207
|
+
while ((match = ampersandRegex.exec(content)) !== null) {
|
|
208
|
+
const index = match.index ?? 0;
|
|
209
|
+
const before = content.substring(0, index);
|
|
210
|
+
const line = before.split("\n").length;
|
|
109
211
|
context.messages.push({
|
|
110
212
|
id: "HTM-012",
|
|
111
213
|
severity: "error",
|
|
112
214
|
message: "Unescaped ampersand",
|
|
113
|
-
location: { path }
|
|
215
|
+
location: { path, line }
|
|
114
216
|
});
|
|
115
217
|
}
|
|
116
218
|
}
|
|
@@ -148,6 +250,633 @@ var ContentValidator = class {
|
|
|
148
250
|
location: { path }
|
|
149
251
|
});
|
|
150
252
|
}
|
|
253
|
+
this.checkNavRemoteLinks(context, path, root, epubTypeAttr?.value ?? "");
|
|
254
|
+
}
|
|
255
|
+
checkNavRemoteLinks(context, path, root, epubTypeValue) {
|
|
256
|
+
const navTypes = epubTypeValue.split(/\s+/);
|
|
257
|
+
const isToc = navTypes.includes("toc");
|
|
258
|
+
const isLandmarks = navTypes.includes("landmarks");
|
|
259
|
+
const isPageList = navTypes.includes("page-list");
|
|
260
|
+
if (!isToc && !isLandmarks && !isPageList) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const links = root.find(".//html:a[@href]", { html: "http://www.w3.org/1999/xhtml" });
|
|
264
|
+
for (const link of links) {
|
|
265
|
+
const href = this.getAttribute(link, "href");
|
|
266
|
+
if (href && (href.startsWith("http://") || href.startsWith("https://"))) {
|
|
267
|
+
const navType = isToc ? "toc" : isLandmarks ? "landmarks" : "page-list";
|
|
268
|
+
context.messages.push({
|
|
269
|
+
id: "NAV-010",
|
|
270
|
+
severity: "error",
|
|
271
|
+
message: `"${navType}" nav must not link to remote resources; found link to "${href}"`,
|
|
272
|
+
location: { path }
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
detectScripts(_context, _path, root) {
|
|
278
|
+
const htmlScript = root.get(".//html:script", { html: "http://www.w3.org/1999/xhtml" });
|
|
279
|
+
if (htmlScript) return true;
|
|
280
|
+
const svgScript = root.get(".//svg:script", { svg: "http://www.w3.org/2000/svg" });
|
|
281
|
+
if (svgScript) return true;
|
|
282
|
+
const form = root.get(".//html:form", { html: "http://www.w3.org/1999/xhtml" });
|
|
283
|
+
if (form) return true;
|
|
284
|
+
const elementsWithEvents = root.find(
|
|
285
|
+
".//*[@onclick or @onload or @onmouseover or @onmouseout or @onchange or @onsubmit or @onfocus or @onblur]"
|
|
286
|
+
);
|
|
287
|
+
if (elementsWithEvents.length > 0) return true;
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
detectMathML(_context, _path, root) {
|
|
291
|
+
const mathMLElements = root.find(".//math:*", { math: "http://www.w3.org/1998/Math/MathML" });
|
|
292
|
+
return mathMLElements.length > 0;
|
|
293
|
+
}
|
|
294
|
+
detectSVG(_context, _path, root) {
|
|
295
|
+
const svgElement = root.get(".//html:svg", { html: "http://www.w3.org/1999/xhtml" });
|
|
296
|
+
if (svgElement) return true;
|
|
297
|
+
const rootSvg = root.get(".//svg:svg", { svg: "http://www.w3.org/2000/svg" });
|
|
298
|
+
if (rootSvg) return true;
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Detect if the content document references remote resources that require
|
|
303
|
+
* the "remote-resources" property in the manifest.
|
|
304
|
+
* Per EPUB spec and Java EPUBCheck behavior:
|
|
305
|
+
* - Remote images, audio, video, fonts REQUIRE the property
|
|
306
|
+
* - Remote hyperlinks (<a href>) do NOT require the property
|
|
307
|
+
* - Remote scripts do NOT require the property (scripted property is used instead)
|
|
308
|
+
* - Remote stylesheets DO require the property
|
|
309
|
+
*/
|
|
310
|
+
detectRemoteResources(_context, _path, root) {
|
|
311
|
+
const images = root.find(".//html:img[@src]", { html: "http://www.w3.org/1999/xhtml" });
|
|
312
|
+
for (const img of images) {
|
|
313
|
+
const src = this.getAttribute(img, "src");
|
|
314
|
+
if (src && (src.startsWith("http://") || src.startsWith("https://"))) {
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const audio = root.find(".//html:audio[@src]", { html: "http://www.w3.org/1999/xhtml" });
|
|
319
|
+
for (const elem of audio) {
|
|
320
|
+
const src = this.getAttribute(elem, "src");
|
|
321
|
+
if (src && (src.startsWith("http://") || src.startsWith("https://"))) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const video = root.find(".//html:video[@src]", { html: "http://www.w3.org/1999/xhtml" });
|
|
326
|
+
for (const elem of video) {
|
|
327
|
+
const src = this.getAttribute(elem, "src");
|
|
328
|
+
if (src && (src.startsWith("http://") || src.startsWith("https://"))) {
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const sources = root.find(".//html:source[@src]", { html: "http://www.w3.org/1999/xhtml" });
|
|
333
|
+
for (const source of sources) {
|
|
334
|
+
const src = this.getAttribute(source, "src");
|
|
335
|
+
if (src && (src.startsWith("http://") || src.startsWith("https://"))) {
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const linkElements = root.find(".//html:link[@rel and @href]", {
|
|
340
|
+
html: "http://www.w3.org/1999/xhtml"
|
|
341
|
+
});
|
|
342
|
+
for (const linkElem of linkElements) {
|
|
343
|
+
const rel = this.getAttribute(linkElem, "rel");
|
|
344
|
+
const href = this.getAttribute(linkElem, "href");
|
|
345
|
+
if (href && rel?.toLowerCase().includes("stylesheet")) {
|
|
346
|
+
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
checkDiscouragedElements(context, path, root) {
|
|
354
|
+
for (const elemName of DISCOURAGED_ELEMENTS) {
|
|
355
|
+
const element = root.get(`.//html:${elemName}`, { html: "http://www.w3.org/1999/xhtml" });
|
|
356
|
+
if (element) {
|
|
357
|
+
context.messages.push({
|
|
358
|
+
id: "HTM-055",
|
|
359
|
+
severity: "usage",
|
|
360
|
+
message: `The "${elemName}" element is discouraged in EPUB`,
|
|
361
|
+
location: { path }
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
checkAccessibility(context, path, root) {
|
|
367
|
+
const links = root.find(".//html:a", { html: "http://www.w3.org/1999/xhtml" });
|
|
368
|
+
for (const link of links) {
|
|
369
|
+
if (!this.hasAccessibleContent(link)) {
|
|
370
|
+
context.messages.push({
|
|
371
|
+
id: "ACC-004",
|
|
372
|
+
severity: "warning",
|
|
373
|
+
message: "Hyperlink has no accessible text content",
|
|
374
|
+
location: { path }
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const images = root.find(".//html:img", { html: "http://www.w3.org/1999/xhtml" });
|
|
379
|
+
for (const img of images) {
|
|
380
|
+
const altAttr = this.getAttribute(img, "alt");
|
|
381
|
+
if (altAttr === null) {
|
|
382
|
+
context.messages.push({
|
|
383
|
+
id: "ACC-005",
|
|
384
|
+
severity: "warning",
|
|
385
|
+
message: "Image is missing alt attribute",
|
|
386
|
+
location: { path }
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const svgLinks = root.find(".//svg:a", {
|
|
391
|
+
svg: "http://www.w3.org/2000/svg",
|
|
392
|
+
xlink: "http://www.w3.org/1999/xlink"
|
|
393
|
+
});
|
|
394
|
+
for (const svgLink of svgLinks) {
|
|
395
|
+
const svgElem = svgLink;
|
|
396
|
+
const title = svgElem.get("./svg:title", { svg: "http://www.w3.org/2000/svg" });
|
|
397
|
+
const ariaLabel = this.getAttribute(svgElem, "aria-label");
|
|
398
|
+
if (!title && !ariaLabel) {
|
|
399
|
+
context.messages.push({
|
|
400
|
+
id: "ACC-011",
|
|
401
|
+
severity: "usage",
|
|
402
|
+
message: "SVG hyperlink has no accessible name (missing title element or aria-label)",
|
|
403
|
+
location: { path }
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const mathElements = root.find(".//math:math", { math: "http://www.w3.org/1998/Math/MathML" });
|
|
408
|
+
for (const mathElem of mathElements) {
|
|
409
|
+
const elem = mathElem;
|
|
410
|
+
const alttext = elem.attr("alttext");
|
|
411
|
+
const annotation = elem.get('./math:annotation[@encoding="application/x-tex"]', {
|
|
412
|
+
math: "http://www.w3.org/1998/Math/MathML"
|
|
413
|
+
});
|
|
414
|
+
const ariaLabel = this.getAttribute(elem, "aria-label");
|
|
415
|
+
if (!alttext?.value && !annotation && !ariaLabel) {
|
|
416
|
+
context.messages.push({
|
|
417
|
+
id: "ACC-009",
|
|
418
|
+
severity: "usage",
|
|
419
|
+
message: "MathML element should have alttext attribute or annotation for accessibility",
|
|
420
|
+
location: { path }
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
validateImages(context, path, root) {
|
|
426
|
+
const packageDoc = context.packageDocument;
|
|
427
|
+
if (!packageDoc) return;
|
|
428
|
+
const images = root.find(".//html:img[@src]", { html: "http://www.w3.org/1999/xhtml" });
|
|
429
|
+
for (const img of images) {
|
|
430
|
+
const imgElem = img;
|
|
431
|
+
const srcAttr = this.getAttribute(imgElem, "src");
|
|
432
|
+
if (!srcAttr) continue;
|
|
433
|
+
const src = srcAttr;
|
|
434
|
+
const opfDir = context.opfPath?.includes("/") ? context.opfPath.substring(0, context.opfPath.lastIndexOf("/")) : "";
|
|
435
|
+
let fullPath = src;
|
|
436
|
+
if (opfDir && !src.startsWith("http://") && !src.startsWith("https://")) {
|
|
437
|
+
if (src.startsWith("/")) {
|
|
438
|
+
fullPath = src.slice(1);
|
|
439
|
+
} else {
|
|
440
|
+
const parts = opfDir.split("/");
|
|
441
|
+
const relParts = src.split("/");
|
|
442
|
+
for (const part of relParts) {
|
|
443
|
+
if (part === "..") {
|
|
444
|
+
parts.pop();
|
|
445
|
+
} else if (part !== ".") {
|
|
446
|
+
parts.push(part);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
fullPath = parts.join("/");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (src.startsWith("http://") || src.startsWith("https://")) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const manifestItem = packageDoc.manifest.find(
|
|
456
|
+
(item) => fullPath.endsWith(item.href) || item.href.endsWith(fullPath)
|
|
457
|
+
);
|
|
458
|
+
if (!manifestItem) {
|
|
459
|
+
context.messages.push({
|
|
460
|
+
id: "MED-001",
|
|
461
|
+
severity: "error",
|
|
462
|
+
message: `Image src references missing manifest item: ${src}`,
|
|
463
|
+
location: { path }
|
|
464
|
+
});
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const imageMediaTypes = /* @__PURE__ */ new Set([
|
|
468
|
+
"image/gif",
|
|
469
|
+
"image/jpeg",
|
|
470
|
+
"image/jpg",
|
|
471
|
+
"image/png",
|
|
472
|
+
"image/svg+xml",
|
|
473
|
+
"image/webp"
|
|
474
|
+
]);
|
|
475
|
+
if (!imageMediaTypes.has(manifestItem.mediaType)) {
|
|
476
|
+
context.messages.push({
|
|
477
|
+
id: "OPF-051",
|
|
478
|
+
severity: "error",
|
|
479
|
+
message: `Image has invalid media type "${manifestItem.mediaType}": ${src}`,
|
|
480
|
+
location: { path }
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
validateEpubTypes(context, path, root) {
|
|
486
|
+
const epubTypeElements = root.find(".//*[@epub:type]", {
|
|
487
|
+
epub: "http://www.idpf.org/2007/ops"
|
|
488
|
+
});
|
|
489
|
+
const knownPrefixes = /* @__PURE__ */ new Set([
|
|
490
|
+
"",
|
|
491
|
+
"http://idpf.org/epub/structure/v1/",
|
|
492
|
+
"http://idpf.org/epub/vocab/structure/",
|
|
493
|
+
"http://www.idpf.org/2007/ops"
|
|
494
|
+
]);
|
|
495
|
+
for (const elem of epubTypeElements) {
|
|
496
|
+
const elemTyped = elem;
|
|
497
|
+
const epubTypeAttr = elemTyped.attr("epub:type");
|
|
498
|
+
if (!epubTypeAttr?.value) continue;
|
|
499
|
+
const epubTypeValue = epubTypeAttr.value;
|
|
500
|
+
for (const part of epubTypeValue.split(/\s+/)) {
|
|
501
|
+
const prefix = part.includes(":") ? part.substring(0, part.indexOf(":")) : "";
|
|
502
|
+
if (!knownPrefixes.has(prefix) && !prefix.startsWith("http://") && !prefix.startsWith("https://")) {
|
|
503
|
+
context.messages.push({
|
|
504
|
+
id: "OPF-088",
|
|
505
|
+
severity: "usage",
|
|
506
|
+
message: `Unknown epub:type prefix "${prefix}": ${epubTypeValue}`,
|
|
507
|
+
location: { path }
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
validateStylesheetLinks(context, path, root) {
|
|
514
|
+
const linkElements = root.find(".//html:link[@rel]", { html: "http://www.w3.org/1999/xhtml" });
|
|
515
|
+
const stylesheetTitles = /* @__PURE__ */ new Map();
|
|
516
|
+
for (const linkElem of linkElements) {
|
|
517
|
+
const elem = linkElem;
|
|
518
|
+
const relAttr = this.getAttribute(elem, "rel");
|
|
519
|
+
const titleAttr = this.getAttribute(elem, "title");
|
|
520
|
+
const hrefAttr = this.getAttribute(elem, "href");
|
|
521
|
+
if (!relAttr || !hrefAttr) continue;
|
|
522
|
+
const rel = relAttr.toLowerCase();
|
|
523
|
+
const rels = rel.split(/\s+/);
|
|
524
|
+
if (rels.includes("stylesheet")) {
|
|
525
|
+
const isAlternate = rels.includes("alternate");
|
|
526
|
+
if (isAlternate && !titleAttr) {
|
|
527
|
+
context.messages.push({
|
|
528
|
+
id: "CSS-015",
|
|
529
|
+
severity: "error",
|
|
530
|
+
message: "Alternate stylesheet must have a title attribute",
|
|
531
|
+
location: { path }
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
if (titleAttr) {
|
|
535
|
+
const key = `${titleAttr}:${isAlternate ? "alt" : "persistent"}`;
|
|
536
|
+
const expectedRel = isAlternate ? "alternate" : "persistent";
|
|
537
|
+
const existing = stylesheetTitles.get(key);
|
|
538
|
+
if (existing) {
|
|
539
|
+
if (!existing.has(expectedRel)) {
|
|
540
|
+
context.messages.push({
|
|
541
|
+
id: "CSS-005",
|
|
542
|
+
severity: "error",
|
|
543
|
+
message: `Stylesheet with title "${titleAttr}" conflicts with another stylesheet with same title`,
|
|
544
|
+
location: { path }
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
existing.add(expectedRel);
|
|
548
|
+
} else {
|
|
549
|
+
stylesheetTitles.set(key, /* @__PURE__ */ new Set([expectedRel]));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
hasAccessibleContent(element) {
|
|
556
|
+
const textContent = element.content;
|
|
557
|
+
if (textContent && textContent.trim().length > 0) {
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
const ariaLabel = this.getAttribute(element, "aria-label");
|
|
561
|
+
if (ariaLabel && ariaLabel.trim().length > 0) {
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
const img = element.get("./html:img[@alt]", { html: "http://www.w3.org/1999/xhtml" });
|
|
565
|
+
if (img) {
|
|
566
|
+
const alt = this.getAttribute(img, "alt");
|
|
567
|
+
if (alt && alt.trim().length > 0) {
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const title = this.getAttribute(element, "title");
|
|
572
|
+
if (title && title.trim().length > 0) {
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
getAttribute(element, name) {
|
|
578
|
+
if (!("attrs" in element)) return null;
|
|
579
|
+
const attrs = element.attrs;
|
|
580
|
+
const attr = attrs.find((a) => a.name === name);
|
|
581
|
+
return attr?.value ?? null;
|
|
582
|
+
}
|
|
583
|
+
validateViewportMeta(context, path, root, manifestItem) {
|
|
584
|
+
const isFixedLayout = manifestItem?.properties?.includes("fixed-layout");
|
|
585
|
+
const metaTags = root.find(".//html:meta[@name]", { html: "http://www.w3.org/1999/xhtml" });
|
|
586
|
+
let hasViewportMeta = false;
|
|
587
|
+
for (const meta of metaTags) {
|
|
588
|
+
const nameAttr = this.getAttribute(meta, "name");
|
|
589
|
+
if (nameAttr === "viewport") {
|
|
590
|
+
hasViewportMeta = true;
|
|
591
|
+
const contentAttr = this.getAttribute(meta, "content");
|
|
592
|
+
if (isFixedLayout) {
|
|
593
|
+
if (!contentAttr) {
|
|
594
|
+
context.messages.push({
|
|
595
|
+
id: "HTM-046",
|
|
596
|
+
severity: "error",
|
|
597
|
+
message: "Viewport meta element should have a content attribute in fixed-layout documents",
|
|
598
|
+
location: { path }
|
|
599
|
+
});
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
const contentLower = contentAttr.toLowerCase();
|
|
603
|
+
if (contentLower.includes("width=device-width")) {
|
|
604
|
+
context.messages.push({
|
|
605
|
+
id: "HTM-047",
|
|
606
|
+
severity: "error",
|
|
607
|
+
message: 'Viewport width should not be set to "device-width" in fixed-layout documents',
|
|
608
|
+
location: { path }
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if (contentLower.includes("height=device-height")) {
|
|
612
|
+
context.messages.push({
|
|
613
|
+
id: "HTM-048",
|
|
614
|
+
severity: "error",
|
|
615
|
+
message: 'Viewport height should not be set to "device-height" in fixed-layout documents',
|
|
616
|
+
location: { path }
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
} else {
|
|
620
|
+
context.messages.push({
|
|
621
|
+
id: "HTM-060b",
|
|
622
|
+
severity: "usage",
|
|
623
|
+
message: `EPUB reading systems must ignore viewport meta elements in reflowable documents; viewport declaration "${contentAttr ?? ""}" will be ignored`,
|
|
624
|
+
location: { path }
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (isFixedLayout && !hasViewportMeta) {
|
|
630
|
+
context.messages.push({
|
|
631
|
+
id: "HTM-049",
|
|
632
|
+
severity: "info",
|
|
633
|
+
message: "Fixed-layout document should include a viewport meta element",
|
|
634
|
+
location: { path }
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
extractAndRegisterIDs(path, root, registry) {
|
|
639
|
+
const elementsWithId = root.find(".//*[@id]");
|
|
640
|
+
for (const elem of elementsWithId) {
|
|
641
|
+
const id = this.getAttribute(elem, "id");
|
|
642
|
+
if (id) {
|
|
643
|
+
registry.registerID(path, id);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
extractAndRegisterHyperlinks(path, root, opfDir, refValidator) {
|
|
648
|
+
const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
|
|
649
|
+
const links = root.find(".//html:a[@href]", { html: "http://www.w3.org/1999/xhtml" });
|
|
650
|
+
for (const link of links) {
|
|
651
|
+
const href = this.getAttribute(link, "href");
|
|
652
|
+
if (!href) continue;
|
|
653
|
+
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (href.startsWith("mailto:") || href.startsWith("tel:")) {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (href.startsWith("#")) {
|
|
660
|
+
const targetResource2 = path;
|
|
661
|
+
const fragment = href.slice(1);
|
|
662
|
+
refValidator.addReference({
|
|
663
|
+
url: href,
|
|
664
|
+
targetResource: targetResource2,
|
|
665
|
+
fragment,
|
|
666
|
+
type: "hyperlink" /* HYPERLINK */,
|
|
667
|
+
location: { path }
|
|
668
|
+
});
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
|
|
672
|
+
const hashIndex = resolvedPath.indexOf("#");
|
|
673
|
+
const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
|
|
674
|
+
const fragmentPart = hashIndex >= 0 ? resolvedPath.slice(hashIndex + 1) : void 0;
|
|
675
|
+
const ref = {
|
|
676
|
+
url: href,
|
|
677
|
+
targetResource,
|
|
678
|
+
type: "hyperlink" /* HYPERLINK */,
|
|
679
|
+
location: { path }
|
|
680
|
+
};
|
|
681
|
+
if (fragmentPart) {
|
|
682
|
+
ref.fragment = fragmentPart;
|
|
683
|
+
}
|
|
684
|
+
refValidator.addReference(ref);
|
|
685
|
+
}
|
|
686
|
+
const svgLinks = root.find(".//svg:a", {
|
|
687
|
+
svg: "http://www.w3.org/2000/svg",
|
|
688
|
+
xlink: "http://www.w3.org/1999/xlink"
|
|
689
|
+
});
|
|
690
|
+
for (const link of svgLinks) {
|
|
691
|
+
const elem = link;
|
|
692
|
+
const href = this.getAttribute(elem, "xlink:href") ?? this.getAttribute(elem, "href");
|
|
693
|
+
if (!href) continue;
|
|
694
|
+
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (href.startsWith("#")) {
|
|
698
|
+
const targetResource2 = path;
|
|
699
|
+
const fragment = href.slice(1);
|
|
700
|
+
refValidator.addReference({
|
|
701
|
+
url: href,
|
|
702
|
+
targetResource: targetResource2,
|
|
703
|
+
fragment,
|
|
704
|
+
type: "hyperlink" /* HYPERLINK */,
|
|
705
|
+
location: { path }
|
|
706
|
+
});
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
|
|
710
|
+
const hashIndex = resolvedPath.indexOf("#");
|
|
711
|
+
const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
|
|
712
|
+
const svgFragment = hashIndex >= 0 ? resolvedPath.slice(hashIndex + 1) : void 0;
|
|
713
|
+
const svgRef = {
|
|
714
|
+
url: href,
|
|
715
|
+
targetResource,
|
|
716
|
+
type: "hyperlink" /* HYPERLINK */,
|
|
717
|
+
location: { path }
|
|
718
|
+
};
|
|
719
|
+
if (svgFragment) {
|
|
720
|
+
svgRef.fragment = svgFragment;
|
|
721
|
+
}
|
|
722
|
+
refValidator.addReference(svgRef);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
extractAndRegisterStylesheets(path, root, opfDir, refValidator) {
|
|
726
|
+
const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
|
|
727
|
+
const linkElements = root.find(".//html:link[@href]", { html: "http://www.w3.org/1999/xhtml" });
|
|
728
|
+
for (const linkElem of linkElements) {
|
|
729
|
+
const href = this.getAttribute(linkElem, "href");
|
|
730
|
+
const rel = this.getAttribute(linkElem, "rel");
|
|
731
|
+
if (!href) continue;
|
|
732
|
+
const isStylesheet = rel?.toLowerCase().includes("stylesheet");
|
|
733
|
+
const type = isStylesheet ? "stylesheet" /* STYLESHEET */ : "link" /* LINK */;
|
|
734
|
+
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
735
|
+
refValidator.addReference({
|
|
736
|
+
url: href,
|
|
737
|
+
targetResource: href,
|
|
738
|
+
type,
|
|
739
|
+
location: { path }
|
|
740
|
+
});
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
|
|
744
|
+
const hashIndex = resolvedPath.indexOf("#");
|
|
745
|
+
const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
|
|
746
|
+
refValidator.addReference({
|
|
747
|
+
url: href,
|
|
748
|
+
targetResource,
|
|
749
|
+
type,
|
|
750
|
+
location: { path }
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Parse CSS content and extract @import statements
|
|
756
|
+
*/
|
|
757
|
+
extractCSSImports(cssPath, cssContent, opfDir, refValidator) {
|
|
758
|
+
const cssDir = cssPath.includes("/") ? cssPath.substring(0, cssPath.lastIndexOf("/")) : "";
|
|
759
|
+
const cleanedCSS = cssContent.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
760
|
+
const importRegex = /@import\s+(?:url\s*\(\s*)?["']([^"']+)["']\s*\)?[^;]*;/gi;
|
|
761
|
+
let match;
|
|
762
|
+
while ((match = importRegex.exec(cleanedCSS)) !== null) {
|
|
763
|
+
const importUrl = match[1];
|
|
764
|
+
if (!importUrl) continue;
|
|
765
|
+
if (importUrl.startsWith("http://") || importUrl.startsWith("https://")) {
|
|
766
|
+
refValidator.addReference({
|
|
767
|
+
url: importUrl,
|
|
768
|
+
targetResource: importUrl,
|
|
769
|
+
type: "stylesheet" /* STYLESHEET */,
|
|
770
|
+
location: { path: cssPath }
|
|
771
|
+
});
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
const resolvedPath = this.resolveRelativePath(cssDir, importUrl, opfDir);
|
|
775
|
+
refValidator.addReference({
|
|
776
|
+
url: importUrl,
|
|
777
|
+
targetResource: resolvedPath,
|
|
778
|
+
type: "stylesheet" /* STYLESHEET */,
|
|
779
|
+
location: { path: cssPath }
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
extractAndRegisterImages(path, root, opfDir, refValidator) {
|
|
784
|
+
const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
|
|
785
|
+
const images = root.find(".//html:img[@src]", { html: "http://www.w3.org/1999/xhtml" });
|
|
786
|
+
for (const img of images) {
|
|
787
|
+
const src = this.getAttribute(img, "src");
|
|
788
|
+
if (!src) continue;
|
|
789
|
+
if (src.startsWith("http://") || src.startsWith("https://")) {
|
|
790
|
+
refValidator.addReference({
|
|
791
|
+
url: src,
|
|
792
|
+
targetResource: src,
|
|
793
|
+
type: "image" /* IMAGE */,
|
|
794
|
+
location: { path }
|
|
795
|
+
});
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
const resolvedPath = this.resolveRelativePath(docDir, src, opfDir);
|
|
799
|
+
refValidator.addReference({
|
|
800
|
+
url: src,
|
|
801
|
+
targetResource: resolvedPath,
|
|
802
|
+
type: "image" /* IMAGE */,
|
|
803
|
+
location: { path }
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
let svgImages = [];
|
|
807
|
+
try {
|
|
808
|
+
const svgImagesXlink = root.find(".//svg:image[@xlink:href]", {
|
|
809
|
+
svg: "http://www.w3.org/2000/svg",
|
|
810
|
+
xlink: "http://www.w3.org/1999/xlink"
|
|
811
|
+
});
|
|
812
|
+
const svgImagesHref = root.find(".//svg:image[@href]", {
|
|
813
|
+
svg: "http://www.w3.org/2000/svg"
|
|
814
|
+
});
|
|
815
|
+
svgImages = [...svgImagesXlink, ...svgImagesHref];
|
|
816
|
+
} catch {
|
|
817
|
+
svgImages = [];
|
|
818
|
+
}
|
|
819
|
+
for (const svgImg of svgImages) {
|
|
820
|
+
const elem = svgImg;
|
|
821
|
+
const href = this.getAttribute(elem, "xlink:href") ?? this.getAttribute(elem, "href");
|
|
822
|
+
if (!href) continue;
|
|
823
|
+
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
824
|
+
refValidator.addReference({
|
|
825
|
+
url: href,
|
|
826
|
+
targetResource: href,
|
|
827
|
+
type: "image" /* IMAGE */,
|
|
828
|
+
location: { path }
|
|
829
|
+
});
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
|
|
833
|
+
refValidator.addReference({
|
|
834
|
+
url: href,
|
|
835
|
+
targetResource: resolvedPath,
|
|
836
|
+
type: "image" /* IMAGE */,
|
|
837
|
+
location: { path }
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
const videos = root.find(".//html:video[@poster]", { html: "http://www.w3.org/1999/xhtml" });
|
|
841
|
+
for (const video of videos) {
|
|
842
|
+
const poster = this.getAttribute(video, "poster");
|
|
843
|
+
if (!poster) continue;
|
|
844
|
+
if (poster.startsWith("http://") || poster.startsWith("https://")) {
|
|
845
|
+
refValidator.addReference({
|
|
846
|
+
url: poster,
|
|
847
|
+
targetResource: poster,
|
|
848
|
+
type: "image" /* IMAGE */,
|
|
849
|
+
location: { path }
|
|
850
|
+
});
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
const resolvedPath = this.resolveRelativePath(docDir, poster, opfDir);
|
|
854
|
+
refValidator.addReference({
|
|
855
|
+
url: poster,
|
|
856
|
+
targetResource: resolvedPath,
|
|
857
|
+
type: "image" /* IMAGE */,
|
|
858
|
+
location: { path }
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
resolveRelativePath(docDir, href, _opfDir) {
|
|
863
|
+
const hrefWithoutFragment = href.split("#")[0] ?? href;
|
|
864
|
+
const fragment = href.includes("#") ? href.split("#")[1] : "";
|
|
865
|
+
if (hrefWithoutFragment.startsWith("/")) {
|
|
866
|
+
const result2 = hrefWithoutFragment.slice(1);
|
|
867
|
+
return fragment ? `${result2}#${fragment}` : result2;
|
|
868
|
+
}
|
|
869
|
+
const parts = docDir ? docDir.split("/") : [];
|
|
870
|
+
const relParts = hrefWithoutFragment.split("/");
|
|
871
|
+
for (const part of relParts) {
|
|
872
|
+
if (part === "..") {
|
|
873
|
+
parts.pop();
|
|
874
|
+
} else if (part !== "." && part !== "") {
|
|
875
|
+
parts.push(part);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
const result = parts.join("/");
|
|
879
|
+
return fragment ? `${result}#${fragment}` : result;
|
|
151
880
|
}
|
|
152
881
|
};
|
|
153
882
|
|
|
@@ -230,9 +959,6 @@ function toJSONReport(result) {
|
|
|
230
959
|
);
|
|
231
960
|
}
|
|
232
961
|
var NCXValidator = class {
|
|
233
|
-
/**
|
|
234
|
-
* Validate NCX document
|
|
235
|
-
*/
|
|
236
962
|
validate(context, ncxContent, ncxPath) {
|
|
237
963
|
let doc = null;
|
|
238
964
|
try {
|
|
@@ -270,15 +996,11 @@ var NCXValidator = class {
|
|
|
270
996
|
}
|
|
271
997
|
this.checkUid(context, root, ncxPath);
|
|
272
998
|
this.checkNavMap(context, root, ncxPath);
|
|
999
|
+
this.checkContentSrc(context, root, ncxPath);
|
|
273
1000
|
} finally {
|
|
274
1001
|
doc.dispose();
|
|
275
1002
|
}
|
|
276
1003
|
}
|
|
277
|
-
/**
|
|
278
|
-
* Check for dtb:uid meta element and validate it
|
|
279
|
-
* Note: dtb:uid is recommended but not strictly required by the NCX spec.
|
|
280
|
-
* The original epubcheck does not report an error if it's missing.
|
|
281
|
-
*/
|
|
282
1004
|
checkUid(context, root, path) {
|
|
283
1005
|
const uidMeta = root.get('.//ncx:head/ncx:meta[@name="dtb:uid"]', {
|
|
284
1006
|
ncx: "http://www.daisy.org/z3986/2005/ncx/"
|
|
@@ -300,9 +1022,6 @@ var NCXValidator = class {
|
|
|
300
1022
|
}
|
|
301
1023
|
context.ncxUid = uidContent.trim();
|
|
302
1024
|
}
|
|
303
|
-
/**
|
|
304
|
-
* Check for navMap element
|
|
305
|
-
*/
|
|
306
1025
|
checkNavMap(context, root, path) {
|
|
307
1026
|
const navMapNode = root.get(".//ncx:navMap", { ncx: "http://www.daisy.org/z3986/2005/ncx/" });
|
|
308
1027
|
if (!navMapNode) {
|
|
@@ -314,15 +1033,54 @@ var NCXValidator = class {
|
|
|
314
1033
|
});
|
|
315
1034
|
}
|
|
316
1035
|
}
|
|
1036
|
+
checkContentSrc(context, root, ncxPath) {
|
|
1037
|
+
const contentElements = root.find(".//ncx:content[@src]", {
|
|
1038
|
+
ncx: "http://www.daisy.org/z3986/2005/ncx/"
|
|
1039
|
+
});
|
|
1040
|
+
const ncxDir = ncxPath.includes("/") ? ncxPath.substring(0, ncxPath.lastIndexOf("/")) : "";
|
|
1041
|
+
for (const contentElem of contentElements) {
|
|
1042
|
+
const srcAttr = contentElem.attr("src");
|
|
1043
|
+
const src = srcAttr?.value;
|
|
1044
|
+
if (!src) continue;
|
|
1045
|
+
const srcBase = src.split("#")[0] ?? src;
|
|
1046
|
+
let fullPath = srcBase;
|
|
1047
|
+
if (ncxDir) {
|
|
1048
|
+
if (srcBase.startsWith("/")) {
|
|
1049
|
+
fullPath = srcBase.slice(1);
|
|
1050
|
+
} else {
|
|
1051
|
+
const parts = ncxDir.split("/");
|
|
1052
|
+
const relParts = srcBase.split("/");
|
|
1053
|
+
for (const part of relParts) {
|
|
1054
|
+
if (part === "..") {
|
|
1055
|
+
parts.pop();
|
|
1056
|
+
} else if (part !== ".") {
|
|
1057
|
+
parts.push(part);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
fullPath = parts.join("/");
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (!context.files.has(fullPath) && !srcBase.startsWith("http://") && !srcBase.startsWith("https://")) {
|
|
1064
|
+
context.messages.push({
|
|
1065
|
+
id: "NCX-006",
|
|
1066
|
+
severity: "error",
|
|
1067
|
+
message: `NCX content src references missing file: ${src}`,
|
|
1068
|
+
location: { path: ncxPath }
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
317
1073
|
};
|
|
318
1074
|
var ZipReader = class _ZipReader {
|
|
319
1075
|
files;
|
|
320
1076
|
_paths;
|
|
321
1077
|
_originalOrder;
|
|
322
|
-
|
|
1078
|
+
_rawData;
|
|
1079
|
+
constructor(files, originalOrder, rawData) {
|
|
323
1080
|
this.files = files;
|
|
324
1081
|
this._originalOrder = originalOrder;
|
|
325
1082
|
this._paths = Object.keys(files).sort();
|
|
1083
|
+
this._rawData = rawData;
|
|
326
1084
|
}
|
|
327
1085
|
/**
|
|
328
1086
|
* Open a ZIP file from binary data
|
|
@@ -330,7 +1088,45 @@ var ZipReader = class _ZipReader {
|
|
|
330
1088
|
static open(data) {
|
|
331
1089
|
const files = fflate.unzipSync(data);
|
|
332
1090
|
const originalOrder = Object.keys(files);
|
|
333
|
-
return new _ZipReader(files, originalOrder);
|
|
1091
|
+
return new _ZipReader(files, originalOrder, data);
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Get compression info for the first entry (mimetype) from raw ZIP header
|
|
1095
|
+
* ZIP Local File Header format:
|
|
1096
|
+
* - Offset 0-3: Signature (0x04034b50)
|
|
1097
|
+
* - Offset 4-5: Version needed
|
|
1098
|
+
* - Offset 6-7: General purpose bit flag
|
|
1099
|
+
* - Offset 8-9: Compression method (0=stored, 8=deflated)
|
|
1100
|
+
* - Offset 10-13: Last mod time/date
|
|
1101
|
+
* - Offset 14-17: CRC-32
|
|
1102
|
+
* - Offset 18-21: Compressed size
|
|
1103
|
+
* - Offset 22-25: Uncompressed size
|
|
1104
|
+
* - Offset 26-27: Filename length
|
|
1105
|
+
* - Offset 28-29: Extra field length
|
|
1106
|
+
* - Offset 30+: Filename
|
|
1107
|
+
*/
|
|
1108
|
+
getMimetypeCompressionInfo() {
|
|
1109
|
+
const data = this._rawData;
|
|
1110
|
+
if (data.length < 30) {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
if (data[0] !== 80 || data[1] !== 75 || data[2] !== 3 || data[3] !== 4) {
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
1116
|
+
const compressionMethod = (data[8] ?? 0) | (data[9] ?? 0) << 8;
|
|
1117
|
+
const filenameLength = (data[26] ?? 0) | (data[27] ?? 0) << 8;
|
|
1118
|
+
const extraFieldLength = (data[28] ?? 0) | (data[29] ?? 0) << 8;
|
|
1119
|
+
if (data.length < 30 + filenameLength) {
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
const filenameBytes = data.slice(30, 30 + filenameLength);
|
|
1123
|
+
const filename = fflate.strFromU8(filenameBytes);
|
|
1124
|
+
return {
|
|
1125
|
+
compressionMethod,
|
|
1126
|
+
extraFieldLength,
|
|
1127
|
+
filenameLength,
|
|
1128
|
+
filename
|
|
1129
|
+
};
|
|
334
1130
|
}
|
|
335
1131
|
/**
|
|
336
1132
|
* Get all file paths in the ZIP (sorted alphabetically)
|
|
@@ -408,6 +1204,8 @@ var OCFValidator = class {
|
|
|
408
1204
|
this.validateMimetype(zip, context.messages);
|
|
409
1205
|
this.validateContainer(zip, context);
|
|
410
1206
|
this.validateMetaInf(zip, context.messages);
|
|
1207
|
+
this.validateFilenames(zip, context.messages);
|
|
1208
|
+
this.validateEmptyDirectories(zip, context.messages);
|
|
411
1209
|
}
|
|
412
1210
|
/**
|
|
413
1211
|
* Validate the mimetype file
|
|
@@ -415,28 +1213,54 @@ var OCFValidator = class {
|
|
|
415
1213
|
* Requirements:
|
|
416
1214
|
* - Must exist
|
|
417
1215
|
* - Must be first file in ZIP
|
|
418
|
-
* - Must be uncompressed
|
|
1216
|
+
* - Must be uncompressed (compression method = 0)
|
|
1217
|
+
* - Must have no extra field
|
|
419
1218
|
* - Must contain exactly "application/epub+zip"
|
|
420
1219
|
*/
|
|
421
1220
|
validateMimetype(zip, messages) {
|
|
422
|
-
|
|
1221
|
+
const compressionInfo = zip.getMimetypeCompressionInfo();
|
|
1222
|
+
if (compressionInfo === null) {
|
|
423
1223
|
messages.push({
|
|
424
1224
|
id: "PKG-006",
|
|
425
1225
|
severity: "error",
|
|
426
|
-
message: "
|
|
1226
|
+
message: "Could not read ZIP header",
|
|
427
1227
|
location: { path: "mimetype" }
|
|
428
1228
|
});
|
|
429
1229
|
return;
|
|
430
1230
|
}
|
|
431
|
-
|
|
432
|
-
|
|
1231
|
+
if (compressionInfo.filename !== "mimetype") {
|
|
1232
|
+
messages.push({
|
|
1233
|
+
id: "PKG-006",
|
|
1234
|
+
severity: "error",
|
|
1235
|
+
message: "Mimetype file must be the first file in the ZIP archive",
|
|
1236
|
+
location: { path: "mimetype" }
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
if (compressionInfo.extraFieldLength !== 0) {
|
|
433
1240
|
messages.push({
|
|
434
1241
|
id: "PKG-005",
|
|
435
1242
|
severity: "error",
|
|
436
|
-
message:
|
|
1243
|
+
message: `Mimetype entry must not have an extra field (found ${String(compressionInfo.extraFieldLength)} bytes)`,
|
|
1244
|
+
location: { path: "mimetype" }
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
if (compressionInfo.compressionMethod !== 0) {
|
|
1248
|
+
messages.push({
|
|
1249
|
+
id: "PKG-006",
|
|
1250
|
+
severity: "error",
|
|
1251
|
+
message: "Mimetype file must be uncompressed",
|
|
437
1252
|
location: { path: "mimetype" }
|
|
438
1253
|
});
|
|
439
1254
|
}
|
|
1255
|
+
if (!zip.has("mimetype")) {
|
|
1256
|
+
messages.push({
|
|
1257
|
+
id: "PKG-006",
|
|
1258
|
+
severity: "error",
|
|
1259
|
+
message: "Missing mimetype file",
|
|
1260
|
+
location: { path: "mimetype" }
|
|
1261
|
+
});
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
440
1264
|
const content = zip.readText("mimetype");
|
|
441
1265
|
if (content === void 0) {
|
|
442
1266
|
messages.push({
|
|
@@ -547,6 +1371,94 @@ var OCFValidator = class {
|
|
|
547
1371
|
message: "Missing META-INF directory",
|
|
548
1372
|
location: { path: "META-INF/" }
|
|
549
1373
|
});
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const allowedFiles = /* @__PURE__ */ new Set([
|
|
1377
|
+
"container.xml",
|
|
1378
|
+
"encryption.xml",
|
|
1379
|
+
"signatures.xml",
|
|
1380
|
+
"metadata.xml"
|
|
1381
|
+
]);
|
|
1382
|
+
for (const file of metaInfFiles) {
|
|
1383
|
+
if (file === "META-INF/") continue;
|
|
1384
|
+
const filename = file.replace("META-INF/", "");
|
|
1385
|
+
if (!allowedFiles.has(filename)) {
|
|
1386
|
+
messages.push({
|
|
1387
|
+
id: "PKG-025",
|
|
1388
|
+
severity: "error",
|
|
1389
|
+
message: `File not allowed in META-INF directory: ${filename}`,
|
|
1390
|
+
location: { path: file }
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Validate filenames for invalid characters
|
|
1397
|
+
*/
|
|
1398
|
+
validateFilenames(zip, messages) {
|
|
1399
|
+
for (const path of zip.paths) {
|
|
1400
|
+
if (path === "mimetype") continue;
|
|
1401
|
+
if (path.endsWith("/")) continue;
|
|
1402
|
+
const filename = path.includes("/") ? path.split("/").pop() ?? path : path;
|
|
1403
|
+
if (filename === "" || filename === "." || filename === "..") {
|
|
1404
|
+
messages.push({
|
|
1405
|
+
id: "PKG-009",
|
|
1406
|
+
severity: "error",
|
|
1407
|
+
message: `Invalid filename: "${path}"`,
|
|
1408
|
+
location: { path }
|
|
1409
|
+
});
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
for (let i = 0; i < filename.length; i++) {
|
|
1413
|
+
const code = filename.charCodeAt(i);
|
|
1414
|
+
if (code < 32 || code === 127 || code >= 128 && code <= 159) {
|
|
1415
|
+
messages.push({
|
|
1416
|
+
id: "PKG-010",
|
|
1417
|
+
severity: "error",
|
|
1418
|
+
message: `Filename contains control character: "${path}"`,
|
|
1419
|
+
location: { path }
|
|
1420
|
+
});
|
|
1421
|
+
break;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
const specialChars = '<>:"|?*';
|
|
1425
|
+
for (const char of specialChars) {
|
|
1426
|
+
if (filename.includes(char)) {
|
|
1427
|
+
messages.push({
|
|
1428
|
+
id: "PKG-011",
|
|
1429
|
+
severity: "error",
|
|
1430
|
+
message: `Filename contains special character: "${path}"`,
|
|
1431
|
+
location: { path }
|
|
1432
|
+
});
|
|
1433
|
+
break;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Validate empty directories
|
|
1440
|
+
*/
|
|
1441
|
+
validateEmptyDirectories(zip, messages) {
|
|
1442
|
+
const directories = /* @__PURE__ */ new Set();
|
|
1443
|
+
for (const path of zip.paths) {
|
|
1444
|
+
const parts = path.split("/");
|
|
1445
|
+
for (let i = 1; i < parts.length; i++) {
|
|
1446
|
+
const dir = parts.slice(0, i).join("/") + "/";
|
|
1447
|
+
directories.add(dir);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
for (const dir of directories) {
|
|
1451
|
+
if (dir !== "META-INF/" && dir !== "OEBPS/" && dir !== "OPS/") {
|
|
1452
|
+
const filesInDir = zip.paths.filter((p) => p.startsWith(dir) && p !== dir);
|
|
1453
|
+
if (filesInDir.length === 0) {
|
|
1454
|
+
messages.push({
|
|
1455
|
+
id: "PKG-014",
|
|
1456
|
+
severity: "warning",
|
|
1457
|
+
message: `Empty directory found: ${dir}`,
|
|
1458
|
+
location: { path: dir }
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
550
1462
|
}
|
|
551
1463
|
}
|
|
552
1464
|
};
|
|
@@ -583,6 +1495,7 @@ function parseOPF(xml) {
|
|
|
583
1495
|
const spineResult = parseSpine(spineSection, spineAttrs);
|
|
584
1496
|
const guideSection = extractSection(xml, "guide");
|
|
585
1497
|
const guide = parseGuide(guideSection);
|
|
1498
|
+
const collections = parseCollections(xml);
|
|
586
1499
|
const result = {
|
|
587
1500
|
version,
|
|
588
1501
|
uniqueIdentifier,
|
|
@@ -591,7 +1504,8 @@ function parseOPF(xml) {
|
|
|
591
1504
|
linkElements,
|
|
592
1505
|
manifest,
|
|
593
1506
|
spine: spineResult.spine,
|
|
594
|
-
guide
|
|
1507
|
+
guide,
|
|
1508
|
+
collections
|
|
595
1509
|
};
|
|
596
1510
|
if (Object.keys(prefixes).length > 0) {
|
|
597
1511
|
result.prefixes = prefixes;
|
|
@@ -825,6 +1739,42 @@ function parseAttributes(attrsStr) {
|
|
|
825
1739
|
function decodeXmlEntities(str) {
|
|
826
1740
|
return str.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/'/g, "'").replace(/"/g, '"');
|
|
827
1741
|
}
|
|
1742
|
+
function parseCollections(xml) {
|
|
1743
|
+
const collections = [];
|
|
1744
|
+
const collectionRegex = /<collection[^>]*\srole=["']([^"']+)["'][^>]*>/g;
|
|
1745
|
+
let collectionMatch;
|
|
1746
|
+
while ((collectionMatch = collectionRegex.exec(xml)) !== null) {
|
|
1747
|
+
const role = collectionMatch[1];
|
|
1748
|
+
if (!role) continue;
|
|
1749
|
+
const collectionStart = collectionMatch.index;
|
|
1750
|
+
const collectionStartTag = collectionMatch[0];
|
|
1751
|
+
const idMatch = /id=["']([^"']+)["']/.exec(collectionStartTag);
|
|
1752
|
+
const id = idMatch?.[1];
|
|
1753
|
+
const nameMatch = /name=["']([^"']+)["']/.exec(collectionStartTag);
|
|
1754
|
+
const name = nameMatch?.[1];
|
|
1755
|
+
const links = [];
|
|
1756
|
+
const linkRegex = /<link[^>]*\shref=["']([^"']+)["'][^>]*\/?>/g;
|
|
1757
|
+
const closingTag = `</collection>`;
|
|
1758
|
+
const closingIndex = xml.indexOf(closingTag, collectionStart);
|
|
1759
|
+
const collectionEnd = closingIndex >= 0 ? closingIndex + closingTag.length : xml.length;
|
|
1760
|
+
const collectionXml = xml.slice(collectionStart, collectionEnd);
|
|
1761
|
+
let linkMatch;
|
|
1762
|
+
while ((linkMatch = linkRegex.exec(collectionXml)) !== null) {
|
|
1763
|
+
const href = linkMatch[1];
|
|
1764
|
+
if (href) {
|
|
1765
|
+
links.push(decodeXmlEntities(href));
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
const collection = {
|
|
1769
|
+
role,
|
|
1770
|
+
links
|
|
1771
|
+
};
|
|
1772
|
+
if (id) collection.id = id;
|
|
1773
|
+
if (name) collection.name = name;
|
|
1774
|
+
collections.push(collection);
|
|
1775
|
+
}
|
|
1776
|
+
return collections;
|
|
1777
|
+
}
|
|
828
1778
|
|
|
829
1779
|
// src/opf/types.ts
|
|
830
1780
|
var ITEM_PROPERTIES = /* @__PURE__ */ new Set([
|
|
@@ -885,7 +1835,7 @@ var OPFValidator = class {
|
|
|
885
1835
|
this.packageDoc = parseOPF(opfXml);
|
|
886
1836
|
} catch (error) {
|
|
887
1837
|
context.messages.push({
|
|
888
|
-
id: "OPF-
|
|
1838
|
+
id: "OPF-002",
|
|
889
1839
|
severity: "fatal",
|
|
890
1840
|
message: `Failed to parse package document: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
891
1841
|
location: { path: opfPath }
|
|
@@ -903,6 +1853,9 @@ var OPFValidator = class {
|
|
|
903
1853
|
if (this.packageDoc.version === "2.0") {
|
|
904
1854
|
this.validateGuide(context, opfPath);
|
|
905
1855
|
}
|
|
1856
|
+
if (this.packageDoc.version.startsWith("3.")) {
|
|
1857
|
+
this.validateCollections(context, opfPath);
|
|
1858
|
+
}
|
|
906
1859
|
}
|
|
907
1860
|
/**
|
|
908
1861
|
* Build lookup maps for manifest items
|
|
@@ -920,6 +1873,15 @@ var OPFValidator = class {
|
|
|
920
1873
|
*/
|
|
921
1874
|
validatePackageAttributes(context, opfPath) {
|
|
922
1875
|
if (!this.packageDoc) return;
|
|
1876
|
+
const validVersions = /* @__PURE__ */ new Set(["2.0", "3.0", "3.1", "3.2", "3.3"]);
|
|
1877
|
+
if (!validVersions.has(this.packageDoc.version)) {
|
|
1878
|
+
context.messages.push({
|
|
1879
|
+
id: "OPF-001",
|
|
1880
|
+
severity: "error",
|
|
1881
|
+
message: `Invalid package version "${this.packageDoc.version}"; must be one of: ${Array.from(validVersions).join(", ")}`,
|
|
1882
|
+
location: { path: opfPath }
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
923
1885
|
if (!this.packageDoc.uniqueIdentifier) {
|
|
924
1886
|
context.messages.push({
|
|
925
1887
|
id: "OPF-048",
|
|
@@ -948,6 +1910,15 @@ var OPFValidator = class {
|
|
|
948
1910
|
validateMetadata(context, opfPath) {
|
|
949
1911
|
if (!this.packageDoc) return;
|
|
950
1912
|
const dcElements = this.packageDoc.dcElements;
|
|
1913
|
+
if (dcElements.length === 0 && this.packageDoc.metaElements.length === 0) {
|
|
1914
|
+
context.messages.push({
|
|
1915
|
+
id: "OPF-072",
|
|
1916
|
+
severity: "error",
|
|
1917
|
+
message: "Metadata section is empty",
|
|
1918
|
+
location: { path: opfPath }
|
|
1919
|
+
});
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
951
1922
|
const hasIdentifier = dcElements.some((dc) => dc.name === "identifier");
|
|
952
1923
|
const hasTitle = dcElements.some((dc) => dc.name === "title");
|
|
953
1924
|
const hasLanguage = dcElements.some((dc) => dc.name === "language");
|
|
@@ -986,18 +1957,144 @@ var OPFValidator = class {
|
|
|
986
1957
|
});
|
|
987
1958
|
}
|
|
988
1959
|
}
|
|
1960
|
+
if (dc.name === "date" && dc.value) {
|
|
1961
|
+
if (!isValidW3CDateFormat(dc.value)) {
|
|
1962
|
+
context.messages.push({
|
|
1963
|
+
id: "OPF-053",
|
|
1964
|
+
severity: "error",
|
|
1965
|
+
message: `Invalid date format "${dc.value}"; must be W3C date format (ISO 8601)`,
|
|
1966
|
+
location: { path: opfPath }
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
if (dc.name === "creator" && dc.attributes) {
|
|
1971
|
+
const opfRole = dc.attributes["opf:role"];
|
|
1972
|
+
if (opfRole?.startsWith("marc:")) {
|
|
1973
|
+
const relatorCode = opfRole.substring(5);
|
|
1974
|
+
const validRelatorCodes = /* @__PURE__ */ new Set([
|
|
1975
|
+
"arr",
|
|
1976
|
+
"aut",
|
|
1977
|
+
"aut",
|
|
1978
|
+
"ccp",
|
|
1979
|
+
"com",
|
|
1980
|
+
"ctb",
|
|
1981
|
+
"csl",
|
|
1982
|
+
"edt",
|
|
1983
|
+
"ill",
|
|
1984
|
+
"itr",
|
|
1985
|
+
"pbl",
|
|
1986
|
+
"pdr",
|
|
1987
|
+
"prt",
|
|
1988
|
+
"trl",
|
|
1989
|
+
"cre",
|
|
1990
|
+
"art",
|
|
1991
|
+
"ctb",
|
|
1992
|
+
"edt",
|
|
1993
|
+
"pfr",
|
|
1994
|
+
"red",
|
|
1995
|
+
"rev",
|
|
1996
|
+
"spn",
|
|
1997
|
+
"dsx",
|
|
1998
|
+
"pmc",
|
|
1999
|
+
"dte",
|
|
2000
|
+
"ove",
|
|
2001
|
+
"trc",
|
|
2002
|
+
"ldr",
|
|
2003
|
+
"led",
|
|
2004
|
+
"prg",
|
|
2005
|
+
"rap",
|
|
2006
|
+
"rce",
|
|
2007
|
+
"rpc",
|
|
2008
|
+
"rtr",
|
|
2009
|
+
"sad",
|
|
2010
|
+
"sgn",
|
|
2011
|
+
"tce",
|
|
2012
|
+
"aac",
|
|
2013
|
+
"acq",
|
|
2014
|
+
"ant",
|
|
2015
|
+
"arr",
|
|
2016
|
+
"art",
|
|
2017
|
+
"ard",
|
|
2018
|
+
"asg",
|
|
2019
|
+
"aus",
|
|
2020
|
+
"aft",
|
|
2021
|
+
"bdd",
|
|
2022
|
+
"bdd",
|
|
2023
|
+
"clb",
|
|
2024
|
+
"clc",
|
|
2025
|
+
"drd",
|
|
2026
|
+
"edt",
|
|
2027
|
+
"edt",
|
|
2028
|
+
"fmd",
|
|
2029
|
+
"flm",
|
|
2030
|
+
"fmo",
|
|
2031
|
+
"fpy",
|
|
2032
|
+
"hnr",
|
|
2033
|
+
"ill",
|
|
2034
|
+
"ilt",
|
|
2035
|
+
"img",
|
|
2036
|
+
"itr",
|
|
2037
|
+
"lrg",
|
|
2038
|
+
"lsa",
|
|
2039
|
+
"led",
|
|
2040
|
+
"lee",
|
|
2041
|
+
"lel",
|
|
2042
|
+
"lgd",
|
|
2043
|
+
"lse",
|
|
2044
|
+
"mfr",
|
|
2045
|
+
"mod",
|
|
2046
|
+
"mon",
|
|
2047
|
+
"mus",
|
|
2048
|
+
"nrt",
|
|
2049
|
+
"ogt",
|
|
2050
|
+
"org",
|
|
2051
|
+
"oth",
|
|
2052
|
+
"pnt",
|
|
2053
|
+
"ppa",
|
|
2054
|
+
"prv",
|
|
2055
|
+
"pup",
|
|
2056
|
+
"red",
|
|
2057
|
+
"rev",
|
|
2058
|
+
"rsg",
|
|
2059
|
+
"srv",
|
|
2060
|
+
"stn",
|
|
2061
|
+
"stl",
|
|
2062
|
+
"trc",
|
|
2063
|
+
"typ",
|
|
2064
|
+
"vdg",
|
|
2065
|
+
"voc",
|
|
2066
|
+
"wac",
|
|
2067
|
+
"wdc"
|
|
2068
|
+
]);
|
|
2069
|
+
if (!validRelatorCodes.has(relatorCode)) {
|
|
2070
|
+
context.messages.push({
|
|
2071
|
+
id: "OPF-052",
|
|
2072
|
+
severity: "error",
|
|
2073
|
+
message: `Unknown MARC relator code "${relatorCode}" in dc:creator`,
|
|
2074
|
+
location: { path: opfPath }
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
989
2079
|
}
|
|
990
2080
|
if (this.packageDoc.version !== "2.0") {
|
|
991
|
-
const
|
|
2081
|
+
const modifiedMeta = this.packageDoc.metaElements.find(
|
|
992
2082
|
(meta) => meta.property === "dcterms:modified"
|
|
993
2083
|
);
|
|
994
|
-
if (!
|
|
2084
|
+
if (!modifiedMeta) {
|
|
995
2085
|
context.messages.push({
|
|
996
2086
|
id: "OPF-054",
|
|
997
2087
|
severity: "error",
|
|
998
2088
|
message: "EPUB 3 metadata must include a dcterms:modified meta element",
|
|
999
2089
|
location: { path: opfPath }
|
|
1000
2090
|
});
|
|
2091
|
+
} else if (modifiedMeta.value && !isValidW3CDateFormat(modifiedMeta.value)) {
|
|
2092
|
+
context.messages.push({
|
|
2093
|
+
id: "OPF-054",
|
|
2094
|
+
severity: "error",
|
|
2095
|
+
message: `Invalid dcterms:modified date format "${modifiedMeta.value}"; must be W3C date format (ISO 8601)`,
|
|
2096
|
+
location: { path: opfPath }
|
|
2097
|
+
});
|
|
1001
2098
|
}
|
|
1002
2099
|
}
|
|
1003
2100
|
}
|
|
@@ -1036,6 +2133,29 @@ var OPFValidator = class {
|
|
|
1036
2133
|
location: { path: opfPath }
|
|
1037
2134
|
});
|
|
1038
2135
|
}
|
|
2136
|
+
if (!isValidMimeType(item.mediaType)) {
|
|
2137
|
+
context.messages.push({
|
|
2138
|
+
id: "OPF-014",
|
|
2139
|
+
severity: "error",
|
|
2140
|
+
message: `Invalid media-type format "${item.mediaType}" for item "${item.id}"`,
|
|
2141
|
+
location: { path: opfPath }
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
const deprecatedTypes = /* @__PURE__ */ new Map([
|
|
2145
|
+
["text/x-oeb1-document", "OPF-035"],
|
|
2146
|
+
["text/x-oeb1-css", "OPF-037"],
|
|
2147
|
+
["application/x-oeb1-package", "OPF-038"],
|
|
2148
|
+
["text/x-oeb1-html", "OPF-037"]
|
|
2149
|
+
]);
|
|
2150
|
+
const deprecatedId = deprecatedTypes.get(item.mediaType);
|
|
2151
|
+
if (deprecatedId) {
|
|
2152
|
+
context.messages.push({
|
|
2153
|
+
id: deprecatedId,
|
|
2154
|
+
severity: "warning",
|
|
2155
|
+
message: `Deprecated OEB 1.0 media-type "${item.mediaType}" should not be used`,
|
|
2156
|
+
location: { path: opfPath }
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
1039
2159
|
if (this.packageDoc.version !== "2.0" && item.properties) {
|
|
1040
2160
|
for (const prop of item.properties) {
|
|
1041
2161
|
if (!ITEM_PROPERTIES.has(prop) && !prop.includes(":")) {
|
|
@@ -1066,6 +2186,16 @@ var OPFValidator = class {
|
|
|
1066
2186
|
location: { path: opfPath }
|
|
1067
2187
|
});
|
|
1068
2188
|
}
|
|
2189
|
+
if (this.packageDoc.version !== "2.0" && (item.href.startsWith("http://") || item.href.startsWith("https://"))) {
|
|
2190
|
+
if (!item.properties?.includes("remote-resources")) {
|
|
2191
|
+
context.messages.push({
|
|
2192
|
+
id: "RSC-006",
|
|
2193
|
+
severity: "error",
|
|
2194
|
+
message: `Manifest item "${item.id}" references remote resource but is missing "remote-resources" property`,
|
|
2195
|
+
location: { path: opfPath }
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
1069
2199
|
}
|
|
1070
2200
|
if (this.packageDoc.version !== "2.0") {
|
|
1071
2201
|
const hasNav = this.packageDoc.manifest.some((item) => item.properties?.includes("nav"));
|
|
@@ -1078,6 +2208,31 @@ var OPFValidator = class {
|
|
|
1078
2208
|
});
|
|
1079
2209
|
}
|
|
1080
2210
|
}
|
|
2211
|
+
this.checkUndeclaredResources(context, opfPath);
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Check for files in container that are not declared in manifest
|
|
2215
|
+
*/
|
|
2216
|
+
checkUndeclaredResources(context, opfPath) {
|
|
2217
|
+
if (!this.packageDoc) return;
|
|
2218
|
+
const declaredPaths = /* @__PURE__ */ new Set();
|
|
2219
|
+
for (const item of this.packageDoc.manifest) {
|
|
2220
|
+
const fullPath = resolvePath(opfPath, item.href);
|
|
2221
|
+
declaredPaths.add(fullPath);
|
|
2222
|
+
}
|
|
2223
|
+
declaredPaths.add(opfPath);
|
|
2224
|
+
for (const filePath of context.files.keys()) {
|
|
2225
|
+
if (filePath.endsWith("/")) continue;
|
|
2226
|
+
if (filePath.startsWith("META-INF/")) continue;
|
|
2227
|
+
if (filePath === "mimetype") continue;
|
|
2228
|
+
if (declaredPaths.has(filePath)) continue;
|
|
2229
|
+
context.messages.push({
|
|
2230
|
+
id: "RSC-008",
|
|
2231
|
+
severity: "error",
|
|
2232
|
+
message: `File in container is not declared in manifest: ${filePath}`,
|
|
2233
|
+
location: { path: filePath }
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
1081
2236
|
}
|
|
1082
2237
|
/**
|
|
1083
2238
|
* Validate spine section
|
|
@@ -1230,6 +2385,89 @@ var OPFValidator = class {
|
|
|
1230
2385
|
}
|
|
1231
2386
|
}
|
|
1232
2387
|
}
|
|
2388
|
+
validateCollections(context, opfPath) {
|
|
2389
|
+
if (!this.packageDoc) return;
|
|
2390
|
+
const collections = this.packageDoc.collections;
|
|
2391
|
+
if (collections.length === 0) {
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
const validRoles = /* @__PURE__ */ new Set(["dictionary", "index", "preview", "recordings"]);
|
|
2395
|
+
for (const collection of collections) {
|
|
2396
|
+
if (!validRoles.has(collection.role)) {
|
|
2397
|
+
context.messages.push({
|
|
2398
|
+
id: "OPF-071",
|
|
2399
|
+
severity: "warning",
|
|
2400
|
+
message: `Unknown collection role: "${collection.role}"`,
|
|
2401
|
+
location: { path: opfPath }
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
if (collection.role === "dictionary") {
|
|
2405
|
+
if (!collection.name || collection.name.trim() === "") {
|
|
2406
|
+
context.messages.push({
|
|
2407
|
+
id: "OPF-072",
|
|
2408
|
+
severity: "error",
|
|
2409
|
+
message: "Dictionary collection must have a name attribute",
|
|
2410
|
+
location: { path: opfPath }
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
for (const linkHref of collection.links) {
|
|
2414
|
+
const manifestItem = this.manifestByHref.get(linkHref);
|
|
2415
|
+
if (!manifestItem) {
|
|
2416
|
+
context.messages.push({
|
|
2417
|
+
id: "OPF-073",
|
|
2418
|
+
severity: "error",
|
|
2419
|
+
message: `Collection link "${linkHref}" references non-existent manifest item`,
|
|
2420
|
+
location: { path: opfPath }
|
|
2421
|
+
});
|
|
2422
|
+
continue;
|
|
2423
|
+
}
|
|
2424
|
+
if (manifestItem.mediaType !== "application/xhtml+xml" && manifestItem.mediaType !== "image/svg+xml") {
|
|
2425
|
+
context.messages.push({
|
|
2426
|
+
id: "OPF-074",
|
|
2427
|
+
severity: "error",
|
|
2428
|
+
message: `Dictionary collection item "${linkHref}" must be an XHTML or SVG document`,
|
|
2429
|
+
location: { path: opfPath }
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
if (collection.role === "index") {
|
|
2435
|
+
for (const linkHref of collection.links) {
|
|
2436
|
+
const manifestItem = this.manifestByHref.get(linkHref);
|
|
2437
|
+
if (!manifestItem) {
|
|
2438
|
+
context.messages.push({
|
|
2439
|
+
id: "OPF-073",
|
|
2440
|
+
severity: "error",
|
|
2441
|
+
message: `Collection link "${linkHref}" references non-existent manifest item`,
|
|
2442
|
+
location: { path: opfPath }
|
|
2443
|
+
});
|
|
2444
|
+
continue;
|
|
2445
|
+
}
|
|
2446
|
+
if (manifestItem.mediaType !== "application/xhtml+xml") {
|
|
2447
|
+
context.messages.push({
|
|
2448
|
+
id: "OPF-075",
|
|
2449
|
+
severity: "error",
|
|
2450
|
+
message: `Index collection item "${linkHref}" must be an XHTML document`,
|
|
2451
|
+
location: { path: opfPath }
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
if (collection.role === "preview") {
|
|
2457
|
+
for (const linkHref of collection.links) {
|
|
2458
|
+
const manifestItem = this.manifestByHref.get(linkHref);
|
|
2459
|
+
if (!manifestItem) {
|
|
2460
|
+
context.messages.push({
|
|
2461
|
+
id: "OPF-073",
|
|
2462
|
+
severity: "error",
|
|
2463
|
+
message: `Collection link "${linkHref}" references non-existent manifest item`,
|
|
2464
|
+
location: { path: opfPath }
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
1233
2471
|
};
|
|
1234
2472
|
function isSpineMediaType(mediaType) {
|
|
1235
2473
|
return mediaType === "application/xhtml+xml" || mediaType === "image/svg+xml" || // EPUB 2 also allows these in spine
|
|
@@ -1258,6 +2496,442 @@ function resolvePath(basePath, relativePath) {
|
|
|
1258
2496
|
}
|
|
1259
2497
|
return parts.join("/");
|
|
1260
2498
|
}
|
|
2499
|
+
function isValidMimeType(mediaType) {
|
|
2500
|
+
const mimeTypePattern = /^[a-zA-Z][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_+]*(?:\s*;\s*[a-zA-Z0-9-]+=[^;]+)?$/;
|
|
2501
|
+
if (!mimeTypePattern.test(mediaType)) {
|
|
2502
|
+
return false;
|
|
2503
|
+
}
|
|
2504
|
+
const [type, subtypeWithParams] = mediaType.split("/");
|
|
2505
|
+
if (!type || !subtypeWithParams) return false;
|
|
2506
|
+
const subtype = subtypeWithParams.split(";")[0]?.trim();
|
|
2507
|
+
if (!subtype) return false;
|
|
2508
|
+
if (type.length > 127 || subtype.length > 127) {
|
|
2509
|
+
return false;
|
|
2510
|
+
}
|
|
2511
|
+
return true;
|
|
2512
|
+
}
|
|
2513
|
+
function isValidW3CDateFormat(dateStr) {
|
|
2514
|
+
const trimmed = dateStr.trim();
|
|
2515
|
+
const yearOnlyPattern = /^\d{4}$/;
|
|
2516
|
+
if (yearOnlyPattern.test(trimmed)) {
|
|
2517
|
+
const year = Number(trimmed);
|
|
2518
|
+
return year >= 0 && year <= 9999;
|
|
2519
|
+
}
|
|
2520
|
+
const yearMonthPattern = /^(\d{4})-(\d{2})$/;
|
|
2521
|
+
const yearMonthMatch = yearMonthPattern.exec(trimmed);
|
|
2522
|
+
if (yearMonthMatch) {
|
|
2523
|
+
const year = Number(yearMonthMatch[1]);
|
|
2524
|
+
const month = Number(yearMonthMatch[2]);
|
|
2525
|
+
if (year < 0 || year > 9999) return false;
|
|
2526
|
+
if (month < 1 || month > 12) return false;
|
|
2527
|
+
return true;
|
|
2528
|
+
}
|
|
2529
|
+
const dateOnlyPattern = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
2530
|
+
const dateMatch = dateOnlyPattern.exec(trimmed);
|
|
2531
|
+
if (dateMatch) {
|
|
2532
|
+
const year = Number(dateMatch[1]);
|
|
2533
|
+
const month = Number(dateMatch[2]);
|
|
2534
|
+
const day = Number(dateMatch[3]);
|
|
2535
|
+
if (year < 0 || year > 9999) return false;
|
|
2536
|
+
if (month < 1 || month > 12) return false;
|
|
2537
|
+
const daysInMonth = new Date(year, month, 0).getDate();
|
|
2538
|
+
if (day < 1 || day > daysInMonth) return false;
|
|
2539
|
+
return true;
|
|
2540
|
+
}
|
|
2541
|
+
const dateTimePattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/;
|
|
2542
|
+
const dateTimeMatch = dateTimePattern.exec(trimmed);
|
|
2543
|
+
if (dateTimeMatch) {
|
|
2544
|
+
const year = Number(dateTimeMatch[1]);
|
|
2545
|
+
const month = Number(dateTimeMatch[2]);
|
|
2546
|
+
const day = Number(dateTimeMatch[3]);
|
|
2547
|
+
if (year < 0 || year > 9999) return false;
|
|
2548
|
+
if (month < 1 || month > 12) return false;
|
|
2549
|
+
const daysInMonth = new Date(year, month, 0).getDate();
|
|
2550
|
+
if (day < 1 || day > daysInMonth) return false;
|
|
2551
|
+
const hours = Number(dateTimeMatch[4]);
|
|
2552
|
+
const minutes = Number(dateTimeMatch[5]);
|
|
2553
|
+
const seconds = Number(dateTimeMatch[6]);
|
|
2554
|
+
if (hours < 0 || hours > 23) return false;
|
|
2555
|
+
if (minutes < 0 || minutes > 59) return false;
|
|
2556
|
+
if (seconds < 0 || seconds > 59) return false;
|
|
2557
|
+
return true;
|
|
2558
|
+
}
|
|
2559
|
+
return false;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
// src/references/registry.ts
|
|
2563
|
+
var ResourceRegistry = class {
|
|
2564
|
+
resources;
|
|
2565
|
+
ids;
|
|
2566
|
+
constructor() {
|
|
2567
|
+
this.resources = /* @__PURE__ */ new Map();
|
|
2568
|
+
this.ids = /* @__PURE__ */ new Map();
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2571
|
+
* Register a resource from manifest
|
|
2572
|
+
*/
|
|
2573
|
+
registerResource(resource) {
|
|
2574
|
+
this.resources.set(resource.url, resource);
|
|
2575
|
+
for (const id of resource.ids) {
|
|
2576
|
+
this.registerID(resource.url, id);
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
/**
|
|
2580
|
+
* Register an ID in a resource
|
|
2581
|
+
*/
|
|
2582
|
+
registerID(resourceURL, id) {
|
|
2583
|
+
if (!id || !this.resources.has(resourceURL)) {
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
if (!this.ids.has(resourceURL)) {
|
|
2587
|
+
this.ids.set(resourceURL, /* @__PURE__ */ new Set());
|
|
2588
|
+
}
|
|
2589
|
+
const resourceIDs = this.ids.get(resourceURL);
|
|
2590
|
+
resourceIDs?.add(id);
|
|
2591
|
+
const resource = this.resources.get(resourceURL);
|
|
2592
|
+
if (resource) {
|
|
2593
|
+
resource.ids.add(id);
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Get a resource by URL
|
|
2598
|
+
*/
|
|
2599
|
+
getResource(url) {
|
|
2600
|
+
return this.resources.get(url);
|
|
2601
|
+
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Check if a resource exists
|
|
2604
|
+
*/
|
|
2605
|
+
hasResource(url) {
|
|
2606
|
+
return this.resources.has(url);
|
|
2607
|
+
}
|
|
2608
|
+
/**
|
|
2609
|
+
* Check if an ID exists in a resource
|
|
2610
|
+
*/
|
|
2611
|
+
hasID(resourceURL, id) {
|
|
2612
|
+
const resourceIDs = this.ids.get(resourceURL);
|
|
2613
|
+
return resourceIDs?.has(id) ?? false;
|
|
2614
|
+
}
|
|
2615
|
+
/**
|
|
2616
|
+
* Get all resources
|
|
2617
|
+
*/
|
|
2618
|
+
getAllResources() {
|
|
2619
|
+
return Array.from(this.resources.values());
|
|
2620
|
+
}
|
|
2621
|
+
/**
|
|
2622
|
+
* Get all resource URLs
|
|
2623
|
+
*/
|
|
2624
|
+
getResourceURLs() {
|
|
2625
|
+
return Array.from(this.resources.keys());
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* Get resources in spine
|
|
2629
|
+
*/
|
|
2630
|
+
getSpineResources() {
|
|
2631
|
+
return Array.from(this.resources.values()).filter((r) => r.inSpine);
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* Check if a resource is in spine
|
|
2635
|
+
*/
|
|
2636
|
+
isInSpine(url) {
|
|
2637
|
+
const resource = this.resources.get(url);
|
|
2638
|
+
return resource?.inSpine ?? false;
|
|
2639
|
+
}
|
|
2640
|
+
};
|
|
2641
|
+
|
|
2642
|
+
// src/references/url.ts
|
|
2643
|
+
function parseURL(urlString) {
|
|
2644
|
+
const hashIndex = urlString.indexOf("#");
|
|
2645
|
+
if (hashIndex === -1) {
|
|
2646
|
+
return {
|
|
2647
|
+
url: urlString,
|
|
2648
|
+
resource: urlString,
|
|
2649
|
+
hasFragment: false
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
const resource = urlString.substring(0, hashIndex);
|
|
2653
|
+
const fragment = urlString.substring(hashIndex + 1);
|
|
2654
|
+
const result = {
|
|
2655
|
+
url: urlString,
|
|
2656
|
+
resource,
|
|
2657
|
+
hasFragment: true
|
|
2658
|
+
};
|
|
2659
|
+
if (fragment) {
|
|
2660
|
+
result.fragment = fragment;
|
|
2661
|
+
}
|
|
2662
|
+
return result;
|
|
2663
|
+
}
|
|
2664
|
+
function isDataURL(url) {
|
|
2665
|
+
return url.startsWith("data:");
|
|
2666
|
+
}
|
|
2667
|
+
function isFileURL(url) {
|
|
2668
|
+
return url.startsWith("file:");
|
|
2669
|
+
}
|
|
2670
|
+
function hasAbsolutePath(url) {
|
|
2671
|
+
return url.startsWith("/");
|
|
2672
|
+
}
|
|
2673
|
+
function hasParentDirectoryReference(url) {
|
|
2674
|
+
return url.includes("..");
|
|
2675
|
+
}
|
|
2676
|
+
function isMalformedURL(url) {
|
|
2677
|
+
if (!url) return true;
|
|
2678
|
+
try {
|
|
2679
|
+
if (/[\s<>]/.test(url)) return true;
|
|
2680
|
+
return false;
|
|
2681
|
+
} catch {
|
|
2682
|
+
return true;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
function isHTTPS(url) {
|
|
2686
|
+
return url.startsWith("https://");
|
|
2687
|
+
}
|
|
2688
|
+
function isHTTP(url) {
|
|
2689
|
+
return url.startsWith("http://");
|
|
2690
|
+
}
|
|
2691
|
+
function isRemoteURL(url) {
|
|
2692
|
+
return isHTTP(url) || isHTTPS(url);
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
// src/references/validator.ts
|
|
2696
|
+
var ReferenceValidator = class {
|
|
2697
|
+
registry;
|
|
2698
|
+
version;
|
|
2699
|
+
references = [];
|
|
2700
|
+
constructor(registry, version) {
|
|
2701
|
+
this.registry = registry;
|
|
2702
|
+
this.version = version;
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Register a reference for validation
|
|
2706
|
+
*/
|
|
2707
|
+
addReference(reference) {
|
|
2708
|
+
this.references.push(reference);
|
|
2709
|
+
}
|
|
2710
|
+
/**
|
|
2711
|
+
* Validate all registered references
|
|
2712
|
+
*/
|
|
2713
|
+
validate(context) {
|
|
2714
|
+
for (const reference of this.references) {
|
|
2715
|
+
this.validateReference(context, reference);
|
|
2716
|
+
}
|
|
2717
|
+
this.checkUndeclaredResources(context);
|
|
2718
|
+
}
|
|
2719
|
+
/**
|
|
2720
|
+
* Validate a single reference
|
|
2721
|
+
*/
|
|
2722
|
+
validateReference(context, reference) {
|
|
2723
|
+
const url = reference.url;
|
|
2724
|
+
if (isMalformedURL(url)) {
|
|
2725
|
+
context.messages.push({
|
|
2726
|
+
id: "RSC-020",
|
|
2727
|
+
severity: "error",
|
|
2728
|
+
message: `Malformed URL: ${url}`,
|
|
2729
|
+
location: reference.location
|
|
2730
|
+
});
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
if (isDataURL(url)) {
|
|
2734
|
+
if (this.version.startsWith("3.")) {
|
|
2735
|
+
context.messages.push({
|
|
2736
|
+
id: "RSC-029",
|
|
2737
|
+
severity: "error",
|
|
2738
|
+
message: "Data URLs are not allowed in EPUB 3",
|
|
2739
|
+
location: reference.location
|
|
2740
|
+
});
|
|
2741
|
+
}
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
if (isFileURL(url)) {
|
|
2745
|
+
context.messages.push({
|
|
2746
|
+
id: "RSC-026",
|
|
2747
|
+
severity: "error",
|
|
2748
|
+
message: "File URLs are not allowed",
|
|
2749
|
+
location: reference.location
|
|
2750
|
+
});
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
const resourcePath = reference.targetResource || parseURL(url).resource;
|
|
2754
|
+
const fragment = reference.fragment ?? parseURL(url).fragment;
|
|
2755
|
+
const hasFragment = fragment !== void 0 && fragment !== "";
|
|
2756
|
+
if (!isRemoteURL(url)) {
|
|
2757
|
+
this.validateLocalReference(context, reference, resourcePath);
|
|
2758
|
+
} else {
|
|
2759
|
+
this.validateRemoteReference(context, reference);
|
|
2760
|
+
}
|
|
2761
|
+
if (hasFragment) {
|
|
2762
|
+
this.validateFragment(context, reference, resourcePath, fragment);
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
/**
|
|
2766
|
+
* Validate a local (non-remote) reference
|
|
2767
|
+
*/
|
|
2768
|
+
validateLocalReference(context, reference, resourcePath) {
|
|
2769
|
+
if (hasAbsolutePath(resourcePath)) {
|
|
2770
|
+
context.messages.push({
|
|
2771
|
+
id: "RSC-027",
|
|
2772
|
+
severity: "error",
|
|
2773
|
+
message: "Absolute paths are not allowed in EPUB",
|
|
2774
|
+
location: reference.location
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
const forbiddenParentDirTypes = [
|
|
2778
|
+
"hyperlink" /* HYPERLINK */,
|
|
2779
|
+
"nav-toc-link" /* NAV_TOC_LINK */,
|
|
2780
|
+
"nav-pagelist-link" /* NAV_PAGELIST_LINK */
|
|
2781
|
+
];
|
|
2782
|
+
if (hasParentDirectoryReference(reference.url) && forbiddenParentDirTypes.includes(reference.type)) {
|
|
2783
|
+
context.messages.push({
|
|
2784
|
+
id: "RSC-028",
|
|
2785
|
+
severity: "error",
|
|
2786
|
+
message: "Parent directory references (..) are not allowed",
|
|
2787
|
+
location: reference.location
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
if (!this.registry.hasResource(resourcePath)) {
|
|
2791
|
+
const isLinkRef = reference.type === "link" /* LINK */;
|
|
2792
|
+
context.messages.push({
|
|
2793
|
+
id: isLinkRef ? "RSC-007w" : "RSC-007",
|
|
2794
|
+
severity: isLinkRef ? "warning" : "error",
|
|
2795
|
+
message: `Referenced resource not found in manifest: ${resourcePath}`,
|
|
2796
|
+
location: reference.location
|
|
2797
|
+
});
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
const resource = this.registry.getResource(resourcePath);
|
|
2801
|
+
if (reference.type === "hyperlink" /* HYPERLINK */ && !resource?.inSpine) {
|
|
2802
|
+
context.messages.push({
|
|
2803
|
+
id: "RSC-011",
|
|
2804
|
+
severity: "error",
|
|
2805
|
+
message: "Hyperlinks must reference spine items",
|
|
2806
|
+
location: reference.location
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
if (reference.type === "hyperlink" /* HYPERLINK */ || reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */) {
|
|
2810
|
+
const targetMimeType = resource?.mimeType;
|
|
2811
|
+
if (targetMimeType && !this.isBlessedItemType(targetMimeType, context.version) && !this.isDeprecatedBlessedItemType(targetMimeType) && !resource.hasCoreMediaTypeFallback) {
|
|
2812
|
+
context.messages.push({
|
|
2813
|
+
id: "RSC-010",
|
|
2814
|
+
severity: "error",
|
|
2815
|
+
message: "Publication resource references must point to content documents",
|
|
2816
|
+
location: reference.location
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* Validate a remote reference
|
|
2823
|
+
*/
|
|
2824
|
+
validateRemoteReference(context, reference) {
|
|
2825
|
+
const url = reference.url;
|
|
2826
|
+
if (isHTTP(url) && !isHTTPS(url)) {
|
|
2827
|
+
context.messages.push({
|
|
2828
|
+
id: "RSC-031",
|
|
2829
|
+
severity: "error",
|
|
2830
|
+
message: "Remote resources must use HTTPS",
|
|
2831
|
+
location: reference.location
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
2834
|
+
if (isPublicationResourceReference(reference.type)) {
|
|
2835
|
+
const allowedRemoteTypes = /* @__PURE__ */ new Set([
|
|
2836
|
+
"audio" /* AUDIO */,
|
|
2837
|
+
"video" /* VIDEO */,
|
|
2838
|
+
"font" /* FONT */
|
|
2839
|
+
]);
|
|
2840
|
+
if (!allowedRemoteTypes.has(reference.type)) {
|
|
2841
|
+
context.messages.push({
|
|
2842
|
+
id: "RSC-006",
|
|
2843
|
+
severity: "error",
|
|
2844
|
+
message: "Remote resources are only allowed for audio, video, and fonts",
|
|
2845
|
+
location: reference.location
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Validate a fragment identifier
|
|
2852
|
+
*/
|
|
2853
|
+
validateFragment(context, reference, resourcePath, fragment) {
|
|
2854
|
+
if (!fragment || !this.registry.hasResource(resourcePath)) {
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
const resource = this.registry.getResource(resourcePath);
|
|
2858
|
+
if (reference.type === "stylesheet" /* STYLESHEET */) {
|
|
2859
|
+
context.messages.push({
|
|
2860
|
+
id: "RSC-013",
|
|
2861
|
+
severity: "error",
|
|
2862
|
+
message: "Stylesheet references must not have fragment identifiers",
|
|
2863
|
+
location: reference.location
|
|
2864
|
+
});
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
if (resource?.mimeType === "image/svg+xml") {
|
|
2868
|
+
const hasSVGView = fragment.includes("svgView(") || fragment.includes("viewBox(");
|
|
2869
|
+
if (hasSVGView && reference.type === "hyperlink" /* HYPERLINK */) {
|
|
2870
|
+
context.messages.push({
|
|
2871
|
+
id: "RSC-014",
|
|
2872
|
+
severity: "error",
|
|
2873
|
+
message: "SVG view fragments can only be referenced from SVG documents",
|
|
2874
|
+
location: reference.location
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
if (!this.registry.hasID(resourcePath, fragment)) {
|
|
2879
|
+
context.messages.push({
|
|
2880
|
+
id: "RSC-012",
|
|
2881
|
+
severity: "error",
|
|
2882
|
+
message: `Fragment identifier not found: #${fragment}`,
|
|
2883
|
+
location: reference.location
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
/**
|
|
2888
|
+
* Check for resources that were never referenced
|
|
2889
|
+
* Following Java EPUBCheck OPFChecker30.java logic:
|
|
2890
|
+
* - Skip items in spine (implicitly referenced)
|
|
2891
|
+
* - Skip nav and NCX resources
|
|
2892
|
+
* - Only check for "publication resource references" (not HYPERLINK)
|
|
2893
|
+
*/
|
|
2894
|
+
checkUndeclaredResources(context) {
|
|
2895
|
+
const referencedResources = new Set(
|
|
2896
|
+
this.references.filter((ref) => {
|
|
2897
|
+
if (!isPublicationResourceReference(ref.type)) {
|
|
2898
|
+
return false;
|
|
2899
|
+
}
|
|
2900
|
+
const targetResource = ref.targetResource || parseURL(ref.url).resource;
|
|
2901
|
+
return this.registry.hasResource(targetResource);
|
|
2902
|
+
}).map((ref) => ref.targetResource || parseURL(ref.url).resource)
|
|
2903
|
+
);
|
|
2904
|
+
for (const resource of this.registry.getAllResources()) {
|
|
2905
|
+
if (resource.inSpine) continue;
|
|
2906
|
+
if (referencedResources.has(resource.url)) continue;
|
|
2907
|
+
if (resource.url.includes("nav")) continue;
|
|
2908
|
+
if (resource.url.includes("toc.ncx") || resource.url.includes(".ncx")) continue;
|
|
2909
|
+
if (resource.url.includes("cover-image")) continue;
|
|
2910
|
+
context.messages.push({
|
|
2911
|
+
id: "OPF-097",
|
|
2912
|
+
severity: "usage",
|
|
2913
|
+
message: `Resource declared in manifest but not referenced: ${resource.url}`,
|
|
2914
|
+
location: { path: resource.url }
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
/**
|
|
2919
|
+
* Check if a MIME type is a blessed content document type
|
|
2920
|
+
*/
|
|
2921
|
+
isBlessedItemType(mimeType, version) {
|
|
2922
|
+
if (version === "2.0") {
|
|
2923
|
+
return mimeType === "application/xhtml+xml" || mimeType === "application/x-dtbook+xml";
|
|
2924
|
+
} else {
|
|
2925
|
+
return mimeType === "application/xhtml+xml" || mimeType === "image/svg+xml";
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
/**
|
|
2929
|
+
* Check if a MIME type is a deprecated blessed content document type
|
|
2930
|
+
*/
|
|
2931
|
+
isDeprecatedBlessedItemType(mimeType) {
|
|
2932
|
+
return mimeType === "text/x-oeb1-document" || mimeType === "text/html";
|
|
2933
|
+
}
|
|
2934
|
+
};
|
|
1261
2935
|
var COMPRESSED_SCHEMAS = {
|
|
1262
2936
|
"applications.rng": "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",
|
|
1263
2937
|
"aria.rng": "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",
|
|
@@ -1617,11 +3291,17 @@ var EpubCheck = class _EpubCheck {
|
|
|
1617
3291
|
}
|
|
1618
3292
|
const opfValidator = new OPFValidator();
|
|
1619
3293
|
opfValidator.validate(context);
|
|
3294
|
+
const registry = new ResourceRegistry();
|
|
3295
|
+
const refValidator = new ReferenceValidator(registry, context.version);
|
|
3296
|
+
if (context.packageDocument) {
|
|
3297
|
+
this.populateRegistry(context, registry);
|
|
3298
|
+
}
|
|
1620
3299
|
const contentValidator = new ContentValidator();
|
|
1621
|
-
contentValidator.validate(context);
|
|
3300
|
+
contentValidator.validate(context, registry, refValidator);
|
|
1622
3301
|
if (context.version === "2.0" && context.packageDocument) {
|
|
1623
3302
|
this.validateNCX(context);
|
|
1624
3303
|
}
|
|
3304
|
+
refValidator.validate(context);
|
|
1625
3305
|
const schemaValidator = new SchemaValidator(context);
|
|
1626
3306
|
await schemaValidator.validate();
|
|
1627
3307
|
} catch (error) {
|
|
@@ -1632,7 +3312,16 @@ var EpubCheck = class _EpubCheck {
|
|
|
1632
3312
|
});
|
|
1633
3313
|
}
|
|
1634
3314
|
const elapsedMs = performance.now() - startTime;
|
|
1635
|
-
|
|
3315
|
+
const filteredMessages = context.messages.filter((msg) => {
|
|
3316
|
+
if (!this.options.includeUsage && msg.severity === "usage") {
|
|
3317
|
+
return false;
|
|
3318
|
+
}
|
|
3319
|
+
if (!this.options.includeInfo && msg.severity === "info") {
|
|
3320
|
+
return false;
|
|
3321
|
+
}
|
|
3322
|
+
return true;
|
|
3323
|
+
});
|
|
3324
|
+
return buildReport(filteredMessages, context.version, elapsedMs);
|
|
1636
3325
|
}
|
|
1637
3326
|
/**
|
|
1638
3327
|
* Static method to validate an EPUB file with default options
|
|
@@ -1676,20 +3365,20 @@ var EpubCheck = class _EpubCheck {
|
|
|
1676
3365
|
const ncxContent = new TextDecoder().decode(ncxData);
|
|
1677
3366
|
const ncxValidator = new NCXValidator();
|
|
1678
3367
|
ncxValidator.validate(context, ncxContent, ncxPath);
|
|
1679
|
-
if (context.ncxUid) {
|
|
1680
|
-
const
|
|
1681
|
-
|
|
3368
|
+
if (context.ncxUid && context.packageDocument.uniqueIdentifier) {
|
|
3369
|
+
const uniqueIdRef = context.packageDocument.uniqueIdentifier;
|
|
3370
|
+
const matchingIdentifier = context.packageDocument.dcElements.find(
|
|
3371
|
+
(dc) => dc.name === "identifier" && dc.id === uniqueIdRef
|
|
1682
3372
|
);
|
|
1683
|
-
|
|
1684
|
-
const opfUid =
|
|
3373
|
+
if (matchingIdentifier) {
|
|
3374
|
+
const opfUid = matchingIdentifier.value.trim();
|
|
1685
3375
|
if (context.ncxUid !== opfUid) {
|
|
1686
3376
|
context.messages.push({
|
|
1687
3377
|
id: "NCX-001",
|
|
1688
|
-
severity: "
|
|
3378
|
+
severity: "error",
|
|
1689
3379
|
message: `NCX uid "${context.ncxUid}" does not match OPF unique identifier "${opfUid}"`,
|
|
1690
3380
|
location: { path: ncxPath }
|
|
1691
3381
|
});
|
|
1692
|
-
break;
|
|
1693
3382
|
}
|
|
1694
3383
|
}
|
|
1695
3384
|
}
|
|
@@ -1709,6 +3398,25 @@ var EpubCheck = class _EpubCheck {
|
|
|
1709
3398
|
}
|
|
1710
3399
|
messages.push(message);
|
|
1711
3400
|
}
|
|
3401
|
+
/**
|
|
3402
|
+
* Populate resource registry from package document manifest
|
|
3403
|
+
*/
|
|
3404
|
+
populateRegistry(context, registry) {
|
|
3405
|
+
const packageDoc = context.packageDocument;
|
|
3406
|
+
if (!packageDoc) return;
|
|
3407
|
+
const opfPath = context.opfPath ?? "";
|
|
3408
|
+
const opfDir = opfPath.includes("/") ? opfPath.substring(0, opfPath.lastIndexOf("/")) : "";
|
|
3409
|
+
const spineIdrefs = new Set(packageDoc.spine.map((item) => item.idref));
|
|
3410
|
+
for (const item of packageDoc.manifest) {
|
|
3411
|
+
const fullPath = opfDir ? `${opfDir}/${item.href}` : item.href;
|
|
3412
|
+
registry.registerResource({
|
|
3413
|
+
url: fullPath,
|
|
3414
|
+
mimeType: item.mediaType,
|
|
3415
|
+
inSpine: spineIdrefs.has(item.id),
|
|
3416
|
+
ids: /* @__PURE__ */ new Set()
|
|
3417
|
+
});
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
1712
3420
|
};
|
|
1713
3421
|
|
|
1714
3422
|
// src/messages/message-id.ts
|