@likecoin/epubcheck-ts 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -10
- package/dist/index.cjs +1738 -42
- 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 +1738 -42
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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)`,
|
|
437
1244
|
location: { path: "mimetype" }
|
|
438
1245
|
});
|
|
439
1246
|
}
|
|
1247
|
+
if (compressionInfo.compressionMethod !== 0) {
|
|
1248
|
+
messages.push({
|
|
1249
|
+
id: "PKG-006",
|
|
1250
|
+
severity: "error",
|
|
1251
|
+
message: "Mimetype file must be uncompressed",
|
|
1252
|
+
location: { path: "mimetype" }
|
|
1253
|
+
});
|
|
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,92 @@ 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
|
+
const filename = file.replace("META-INF/", "");
|
|
1384
|
+
if (!allowedFiles.has(filename)) {
|
|
1385
|
+
messages.push({
|
|
1386
|
+
id: "PKG-025",
|
|
1387
|
+
severity: "error",
|
|
1388
|
+
message: `File not allowed in META-INF directory: ${filename}`,
|
|
1389
|
+
location: { path: file }
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Validate filenames for invalid characters
|
|
1396
|
+
*/
|
|
1397
|
+
validateFilenames(zip, messages) {
|
|
1398
|
+
for (const path of zip.paths) {
|
|
1399
|
+
if (path === "mimetype") continue;
|
|
1400
|
+
const filename = path.includes("/") ? path.split("/").pop() ?? path : path;
|
|
1401
|
+
if (filename === "" || filename === "." || filename === "..") {
|
|
1402
|
+
messages.push({
|
|
1403
|
+
id: "PKG-009",
|
|
1404
|
+
severity: "error",
|
|
1405
|
+
message: `Invalid filename: "${path}"`,
|
|
1406
|
+
location: { path }
|
|
1407
|
+
});
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
for (let i = 0; i < filename.length; i++) {
|
|
1411
|
+
const code = filename.charCodeAt(i);
|
|
1412
|
+
if (code < 32 || code === 127 || code >= 128 && code <= 159) {
|
|
1413
|
+
messages.push({
|
|
1414
|
+
id: "PKG-010",
|
|
1415
|
+
severity: "error",
|
|
1416
|
+
message: `Filename contains control character: "${path}"`,
|
|
1417
|
+
location: { path }
|
|
1418
|
+
});
|
|
1419
|
+
break;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const specialChars = '<>:"|?*';
|
|
1423
|
+
for (const char of specialChars) {
|
|
1424
|
+
if (filename.includes(char)) {
|
|
1425
|
+
messages.push({
|
|
1426
|
+
id: "PKG-011",
|
|
1427
|
+
severity: "error",
|
|
1428
|
+
message: `Filename contains special character: "${path}"`,
|
|
1429
|
+
location: { path }
|
|
1430
|
+
});
|
|
1431
|
+
break;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Validate empty directories
|
|
1438
|
+
*/
|
|
1439
|
+
validateEmptyDirectories(zip, messages) {
|
|
1440
|
+
const directories = /* @__PURE__ */ new Set();
|
|
1441
|
+
for (const path of zip.paths) {
|
|
1442
|
+
const parts = path.split("/");
|
|
1443
|
+
for (let i = 1; i < parts.length; i++) {
|
|
1444
|
+
const dir = parts.slice(0, i).join("/") + "/";
|
|
1445
|
+
directories.add(dir);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
for (const dir of directories) {
|
|
1449
|
+
if (dir !== "META-INF/" && dir !== "OEBPS/" && dir !== "OPS/") {
|
|
1450
|
+
const filesInDir = zip.paths.filter((p) => p.startsWith(dir) && p !== dir);
|
|
1451
|
+
if (filesInDir.length === 0) {
|
|
1452
|
+
messages.push({
|
|
1453
|
+
id: "PKG-014",
|
|
1454
|
+
severity: "warning",
|
|
1455
|
+
message: `Empty directory found: ${dir}`,
|
|
1456
|
+
location: { path: dir }
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
550
1460
|
}
|
|
551
1461
|
}
|
|
552
1462
|
};
|
|
@@ -583,6 +1493,7 @@ function parseOPF(xml) {
|
|
|
583
1493
|
const spineResult = parseSpine(spineSection, spineAttrs);
|
|
584
1494
|
const guideSection = extractSection(xml, "guide");
|
|
585
1495
|
const guide = parseGuide(guideSection);
|
|
1496
|
+
const collections = parseCollections(xml);
|
|
586
1497
|
const result = {
|
|
587
1498
|
version,
|
|
588
1499
|
uniqueIdentifier,
|
|
@@ -591,7 +1502,8 @@ function parseOPF(xml) {
|
|
|
591
1502
|
linkElements,
|
|
592
1503
|
manifest,
|
|
593
1504
|
spine: spineResult.spine,
|
|
594
|
-
guide
|
|
1505
|
+
guide,
|
|
1506
|
+
collections
|
|
595
1507
|
};
|
|
596
1508
|
if (Object.keys(prefixes).length > 0) {
|
|
597
1509
|
result.prefixes = prefixes;
|
|
@@ -825,6 +1737,42 @@ function parseAttributes(attrsStr) {
|
|
|
825
1737
|
function decodeXmlEntities(str) {
|
|
826
1738
|
return str.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/'/g, "'").replace(/"/g, '"');
|
|
827
1739
|
}
|
|
1740
|
+
function parseCollections(xml) {
|
|
1741
|
+
const collections = [];
|
|
1742
|
+
const collectionRegex = /<collection[^>]*\srole=["']([^"']+)["'][^>]*>/g;
|
|
1743
|
+
let collectionMatch;
|
|
1744
|
+
while ((collectionMatch = collectionRegex.exec(xml)) !== null) {
|
|
1745
|
+
const role = collectionMatch[1];
|
|
1746
|
+
if (!role) continue;
|
|
1747
|
+
const collectionStart = collectionMatch.index;
|
|
1748
|
+
const collectionStartTag = collectionMatch[0];
|
|
1749
|
+
const idMatch = /id=["']([^"']+)["']/.exec(collectionStartTag);
|
|
1750
|
+
const id = idMatch?.[1];
|
|
1751
|
+
const nameMatch = /name=["']([^"']+)["']/.exec(collectionStartTag);
|
|
1752
|
+
const name = nameMatch?.[1];
|
|
1753
|
+
const links = [];
|
|
1754
|
+
const linkRegex = /<link[^>]*\shref=["']([^"']+)["'][^>]*\/?>/g;
|
|
1755
|
+
const closingTag = `</collection>`;
|
|
1756
|
+
const closingIndex = xml.indexOf(closingTag, collectionStart);
|
|
1757
|
+
const collectionEnd = closingIndex >= 0 ? closingIndex + closingTag.length : xml.length;
|
|
1758
|
+
const collectionXml = xml.slice(collectionStart, collectionEnd);
|
|
1759
|
+
let linkMatch;
|
|
1760
|
+
while ((linkMatch = linkRegex.exec(collectionXml)) !== null) {
|
|
1761
|
+
const href = linkMatch[1];
|
|
1762
|
+
if (href) {
|
|
1763
|
+
links.push(decodeXmlEntities(href));
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
const collection = {
|
|
1767
|
+
role,
|
|
1768
|
+
links
|
|
1769
|
+
};
|
|
1770
|
+
if (id) collection.id = id;
|
|
1771
|
+
if (name) collection.name = name;
|
|
1772
|
+
collections.push(collection);
|
|
1773
|
+
}
|
|
1774
|
+
return collections;
|
|
1775
|
+
}
|
|
828
1776
|
|
|
829
1777
|
// src/opf/types.ts
|
|
830
1778
|
var ITEM_PROPERTIES = /* @__PURE__ */ new Set([
|
|
@@ -885,7 +1833,7 @@ var OPFValidator = class {
|
|
|
885
1833
|
this.packageDoc = parseOPF(opfXml);
|
|
886
1834
|
} catch (error) {
|
|
887
1835
|
context.messages.push({
|
|
888
|
-
id: "OPF-
|
|
1836
|
+
id: "OPF-002",
|
|
889
1837
|
severity: "fatal",
|
|
890
1838
|
message: `Failed to parse package document: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
891
1839
|
location: { path: opfPath }
|
|
@@ -903,6 +1851,9 @@ var OPFValidator = class {
|
|
|
903
1851
|
if (this.packageDoc.version === "2.0") {
|
|
904
1852
|
this.validateGuide(context, opfPath);
|
|
905
1853
|
}
|
|
1854
|
+
if (this.packageDoc.version.startsWith("3.")) {
|
|
1855
|
+
this.validateCollections(context, opfPath);
|
|
1856
|
+
}
|
|
906
1857
|
}
|
|
907
1858
|
/**
|
|
908
1859
|
* Build lookup maps for manifest items
|
|
@@ -920,6 +1871,15 @@ var OPFValidator = class {
|
|
|
920
1871
|
*/
|
|
921
1872
|
validatePackageAttributes(context, opfPath) {
|
|
922
1873
|
if (!this.packageDoc) return;
|
|
1874
|
+
const validVersions = /* @__PURE__ */ new Set(["2.0", "3.0", "3.1", "3.2", "3.3"]);
|
|
1875
|
+
if (!validVersions.has(this.packageDoc.version)) {
|
|
1876
|
+
context.messages.push({
|
|
1877
|
+
id: "OPF-001",
|
|
1878
|
+
severity: "error",
|
|
1879
|
+
message: `Invalid package version "${this.packageDoc.version}"; must be one of: ${Array.from(validVersions).join(", ")}`,
|
|
1880
|
+
location: { path: opfPath }
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
923
1883
|
if (!this.packageDoc.uniqueIdentifier) {
|
|
924
1884
|
context.messages.push({
|
|
925
1885
|
id: "OPF-048",
|
|
@@ -948,6 +1908,15 @@ var OPFValidator = class {
|
|
|
948
1908
|
validateMetadata(context, opfPath) {
|
|
949
1909
|
if (!this.packageDoc) return;
|
|
950
1910
|
const dcElements = this.packageDoc.dcElements;
|
|
1911
|
+
if (dcElements.length === 0 && this.packageDoc.metaElements.length === 0) {
|
|
1912
|
+
context.messages.push({
|
|
1913
|
+
id: "OPF-072",
|
|
1914
|
+
severity: "error",
|
|
1915
|
+
message: "Metadata section is empty",
|
|
1916
|
+
location: { path: opfPath }
|
|
1917
|
+
});
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
951
1920
|
const hasIdentifier = dcElements.some((dc) => dc.name === "identifier");
|
|
952
1921
|
const hasTitle = dcElements.some((dc) => dc.name === "title");
|
|
953
1922
|
const hasLanguage = dcElements.some((dc) => dc.name === "language");
|
|
@@ -979,25 +1948,151 @@ var OPFValidator = class {
|
|
|
979
1948
|
if (dc.name === "language" && dc.value) {
|
|
980
1949
|
if (!isValidLanguageTag(dc.value)) {
|
|
981
1950
|
context.messages.push({
|
|
982
|
-
id: "OPF-092",
|
|
1951
|
+
id: "OPF-092",
|
|
1952
|
+
severity: "error",
|
|
1953
|
+
message: `Invalid language tag: "${dc.value}"`,
|
|
1954
|
+
location: { path: opfPath }
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
if (dc.name === "date" && dc.value) {
|
|
1959
|
+
if (!isValidW3CDateFormat(dc.value)) {
|
|
1960
|
+
context.messages.push({
|
|
1961
|
+
id: "OPF-053",
|
|
983
1962
|
severity: "error",
|
|
984
|
-
message: `Invalid
|
|
1963
|
+
message: `Invalid date format "${dc.value}"; must be W3C date format (ISO 8601)`,
|
|
985
1964
|
location: { path: opfPath }
|
|
986
1965
|
});
|
|
987
1966
|
}
|
|
988
1967
|
}
|
|
1968
|
+
if (dc.name === "creator" && dc.attributes) {
|
|
1969
|
+
const opfRole = dc.attributes["opf:role"];
|
|
1970
|
+
if (opfRole?.startsWith("marc:")) {
|
|
1971
|
+
const relatorCode = opfRole.substring(5);
|
|
1972
|
+
const validRelatorCodes = /* @__PURE__ */ new Set([
|
|
1973
|
+
"arr",
|
|
1974
|
+
"aut",
|
|
1975
|
+
"aut",
|
|
1976
|
+
"ccp",
|
|
1977
|
+
"com",
|
|
1978
|
+
"ctb",
|
|
1979
|
+
"csl",
|
|
1980
|
+
"edt",
|
|
1981
|
+
"ill",
|
|
1982
|
+
"itr",
|
|
1983
|
+
"pbl",
|
|
1984
|
+
"pdr",
|
|
1985
|
+
"prt",
|
|
1986
|
+
"trl",
|
|
1987
|
+
"cre",
|
|
1988
|
+
"art",
|
|
1989
|
+
"ctb",
|
|
1990
|
+
"edt",
|
|
1991
|
+
"pfr",
|
|
1992
|
+
"red",
|
|
1993
|
+
"rev",
|
|
1994
|
+
"spn",
|
|
1995
|
+
"dsx",
|
|
1996
|
+
"pmc",
|
|
1997
|
+
"dte",
|
|
1998
|
+
"ove",
|
|
1999
|
+
"trc",
|
|
2000
|
+
"ldr",
|
|
2001
|
+
"led",
|
|
2002
|
+
"prg",
|
|
2003
|
+
"rap",
|
|
2004
|
+
"rce",
|
|
2005
|
+
"rpc",
|
|
2006
|
+
"rtr",
|
|
2007
|
+
"sad",
|
|
2008
|
+
"sgn",
|
|
2009
|
+
"tce",
|
|
2010
|
+
"aac",
|
|
2011
|
+
"acq",
|
|
2012
|
+
"ant",
|
|
2013
|
+
"arr",
|
|
2014
|
+
"art",
|
|
2015
|
+
"ard",
|
|
2016
|
+
"asg",
|
|
2017
|
+
"aus",
|
|
2018
|
+
"aft",
|
|
2019
|
+
"bdd",
|
|
2020
|
+
"bdd",
|
|
2021
|
+
"clb",
|
|
2022
|
+
"clc",
|
|
2023
|
+
"drd",
|
|
2024
|
+
"edt",
|
|
2025
|
+
"edt",
|
|
2026
|
+
"fmd",
|
|
2027
|
+
"flm",
|
|
2028
|
+
"fmo",
|
|
2029
|
+
"fpy",
|
|
2030
|
+
"hnr",
|
|
2031
|
+
"ill",
|
|
2032
|
+
"ilt",
|
|
2033
|
+
"img",
|
|
2034
|
+
"itr",
|
|
2035
|
+
"lrg",
|
|
2036
|
+
"lsa",
|
|
2037
|
+
"led",
|
|
2038
|
+
"lee",
|
|
2039
|
+
"lel",
|
|
2040
|
+
"lgd",
|
|
2041
|
+
"lse",
|
|
2042
|
+
"mfr",
|
|
2043
|
+
"mod",
|
|
2044
|
+
"mon",
|
|
2045
|
+
"mus",
|
|
2046
|
+
"nrt",
|
|
2047
|
+
"ogt",
|
|
2048
|
+
"org",
|
|
2049
|
+
"oth",
|
|
2050
|
+
"pnt",
|
|
2051
|
+
"ppa",
|
|
2052
|
+
"prv",
|
|
2053
|
+
"pup",
|
|
2054
|
+
"red",
|
|
2055
|
+
"rev",
|
|
2056
|
+
"rsg",
|
|
2057
|
+
"srv",
|
|
2058
|
+
"stn",
|
|
2059
|
+
"stl",
|
|
2060
|
+
"trc",
|
|
2061
|
+
"typ",
|
|
2062
|
+
"vdg",
|
|
2063
|
+
"voc",
|
|
2064
|
+
"wac",
|
|
2065
|
+
"wdc"
|
|
2066
|
+
]);
|
|
2067
|
+
if (!validRelatorCodes.has(relatorCode)) {
|
|
2068
|
+
context.messages.push({
|
|
2069
|
+
id: "OPF-052",
|
|
2070
|
+
severity: "error",
|
|
2071
|
+
message: `Unknown MARC relator code "${relatorCode}" in dc:creator`,
|
|
2072
|
+
location: { path: opfPath }
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
989
2077
|
}
|
|
990
2078
|
if (this.packageDoc.version !== "2.0") {
|
|
991
|
-
const
|
|
2079
|
+
const modifiedMeta = this.packageDoc.metaElements.find(
|
|
992
2080
|
(meta) => meta.property === "dcterms:modified"
|
|
993
2081
|
);
|
|
994
|
-
if (!
|
|
2082
|
+
if (!modifiedMeta) {
|
|
995
2083
|
context.messages.push({
|
|
996
2084
|
id: "OPF-054",
|
|
997
2085
|
severity: "error",
|
|
998
2086
|
message: "EPUB 3 metadata must include a dcterms:modified meta element",
|
|
999
2087
|
location: { path: opfPath }
|
|
1000
2088
|
});
|
|
2089
|
+
} else if (modifiedMeta.value && !isValidW3CDateFormat(modifiedMeta.value)) {
|
|
2090
|
+
context.messages.push({
|
|
2091
|
+
id: "OPF-054",
|
|
2092
|
+
severity: "error",
|
|
2093
|
+
message: `Invalid dcterms:modified date format "${modifiedMeta.value}"; must be W3C date format (ISO 8601)`,
|
|
2094
|
+
location: { path: opfPath }
|
|
2095
|
+
});
|
|
1001
2096
|
}
|
|
1002
2097
|
}
|
|
1003
2098
|
}
|
|
@@ -1036,6 +2131,29 @@ var OPFValidator = class {
|
|
|
1036
2131
|
location: { path: opfPath }
|
|
1037
2132
|
});
|
|
1038
2133
|
}
|
|
2134
|
+
if (!isValidMimeType(item.mediaType)) {
|
|
2135
|
+
context.messages.push({
|
|
2136
|
+
id: "OPF-014",
|
|
2137
|
+
severity: "error",
|
|
2138
|
+
message: `Invalid media-type format "${item.mediaType}" for item "${item.id}"`,
|
|
2139
|
+
location: { path: opfPath }
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
const deprecatedTypes = /* @__PURE__ */ new Map([
|
|
2143
|
+
["text/x-oeb1-document", "OPF-035"],
|
|
2144
|
+
["text/x-oeb1-css", "OPF-037"],
|
|
2145
|
+
["application/x-oeb1-package", "OPF-038"],
|
|
2146
|
+
["text/x-oeb1-html", "OPF-037"]
|
|
2147
|
+
]);
|
|
2148
|
+
const deprecatedId = deprecatedTypes.get(item.mediaType);
|
|
2149
|
+
if (deprecatedId) {
|
|
2150
|
+
context.messages.push({
|
|
2151
|
+
id: deprecatedId,
|
|
2152
|
+
severity: "warning",
|
|
2153
|
+
message: `Deprecated OEB 1.0 media-type "${item.mediaType}" should not be used`,
|
|
2154
|
+
location: { path: opfPath }
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
1039
2157
|
if (this.packageDoc.version !== "2.0" && item.properties) {
|
|
1040
2158
|
for (const prop of item.properties) {
|
|
1041
2159
|
if (!ITEM_PROPERTIES.has(prop) && !prop.includes(":")) {
|
|
@@ -1066,6 +2184,16 @@ var OPFValidator = class {
|
|
|
1066
2184
|
location: { path: opfPath }
|
|
1067
2185
|
});
|
|
1068
2186
|
}
|
|
2187
|
+
if (this.packageDoc.version !== "2.0" && (item.href.startsWith("http://") || item.href.startsWith("https://"))) {
|
|
2188
|
+
if (!item.properties?.includes("remote-resources")) {
|
|
2189
|
+
context.messages.push({
|
|
2190
|
+
id: "RSC-006",
|
|
2191
|
+
severity: "error",
|
|
2192
|
+
message: `Manifest item "${item.id}" references remote resource but is missing "remote-resources" property`,
|
|
2193
|
+
location: { path: opfPath }
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
1069
2197
|
}
|
|
1070
2198
|
if (this.packageDoc.version !== "2.0") {
|
|
1071
2199
|
const hasNav = this.packageDoc.manifest.some((item) => item.properties?.includes("nav"));
|
|
@@ -1078,6 +2206,30 @@ var OPFValidator = class {
|
|
|
1078
2206
|
});
|
|
1079
2207
|
}
|
|
1080
2208
|
}
|
|
2209
|
+
this.checkUndeclaredResources(context, opfPath);
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Check for files in container that are not declared in manifest
|
|
2213
|
+
*/
|
|
2214
|
+
checkUndeclaredResources(context, opfPath) {
|
|
2215
|
+
if (!this.packageDoc) return;
|
|
2216
|
+
const declaredPaths = /* @__PURE__ */ new Set();
|
|
2217
|
+
for (const item of this.packageDoc.manifest) {
|
|
2218
|
+
const fullPath = resolvePath(opfPath, item.href);
|
|
2219
|
+
declaredPaths.add(fullPath);
|
|
2220
|
+
}
|
|
2221
|
+
declaredPaths.add(opfPath);
|
|
2222
|
+
for (const filePath of context.files.keys()) {
|
|
2223
|
+
if (filePath.startsWith("META-INF/")) continue;
|
|
2224
|
+
if (filePath === "mimetype") continue;
|
|
2225
|
+
if (declaredPaths.has(filePath)) continue;
|
|
2226
|
+
context.messages.push({
|
|
2227
|
+
id: "RSC-008",
|
|
2228
|
+
severity: "warning",
|
|
2229
|
+
message: `File in container is not declared in manifest: ${filePath}`,
|
|
2230
|
+
location: { path: filePath }
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
1081
2233
|
}
|
|
1082
2234
|
/**
|
|
1083
2235
|
* Validate spine section
|
|
@@ -1230,6 +2382,89 @@ var OPFValidator = class {
|
|
|
1230
2382
|
}
|
|
1231
2383
|
}
|
|
1232
2384
|
}
|
|
2385
|
+
validateCollections(context, opfPath) {
|
|
2386
|
+
if (!this.packageDoc) return;
|
|
2387
|
+
const collections = this.packageDoc.collections;
|
|
2388
|
+
if (collections.length === 0) {
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
const validRoles = /* @__PURE__ */ new Set(["dictionary", "index", "preview", "recordings"]);
|
|
2392
|
+
for (const collection of collections) {
|
|
2393
|
+
if (!validRoles.has(collection.role)) {
|
|
2394
|
+
context.messages.push({
|
|
2395
|
+
id: "OPF-071",
|
|
2396
|
+
severity: "warning",
|
|
2397
|
+
message: `Unknown collection role: "${collection.role}"`,
|
|
2398
|
+
location: { path: opfPath }
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
if (collection.role === "dictionary") {
|
|
2402
|
+
if (!collection.name || collection.name.trim() === "") {
|
|
2403
|
+
context.messages.push({
|
|
2404
|
+
id: "OPF-072",
|
|
2405
|
+
severity: "error",
|
|
2406
|
+
message: "Dictionary collection must have a name attribute",
|
|
2407
|
+
location: { path: opfPath }
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
for (const linkHref of collection.links) {
|
|
2411
|
+
const manifestItem = this.manifestByHref.get(linkHref);
|
|
2412
|
+
if (!manifestItem) {
|
|
2413
|
+
context.messages.push({
|
|
2414
|
+
id: "OPF-073",
|
|
2415
|
+
severity: "error",
|
|
2416
|
+
message: `Collection link "${linkHref}" references non-existent manifest item`,
|
|
2417
|
+
location: { path: opfPath }
|
|
2418
|
+
});
|
|
2419
|
+
continue;
|
|
2420
|
+
}
|
|
2421
|
+
if (manifestItem.mediaType !== "application/xhtml+xml" && manifestItem.mediaType !== "image/svg+xml") {
|
|
2422
|
+
context.messages.push({
|
|
2423
|
+
id: "OPF-074",
|
|
2424
|
+
severity: "error",
|
|
2425
|
+
message: `Dictionary collection item "${linkHref}" must be an XHTML or SVG document`,
|
|
2426
|
+
location: { path: opfPath }
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
if (collection.role === "index") {
|
|
2432
|
+
for (const linkHref of collection.links) {
|
|
2433
|
+
const manifestItem = this.manifestByHref.get(linkHref);
|
|
2434
|
+
if (!manifestItem) {
|
|
2435
|
+
context.messages.push({
|
|
2436
|
+
id: "OPF-073",
|
|
2437
|
+
severity: "error",
|
|
2438
|
+
message: `Collection link "${linkHref}" references non-existent manifest item`,
|
|
2439
|
+
location: { path: opfPath }
|
|
2440
|
+
});
|
|
2441
|
+
continue;
|
|
2442
|
+
}
|
|
2443
|
+
if (manifestItem.mediaType !== "application/xhtml+xml") {
|
|
2444
|
+
context.messages.push({
|
|
2445
|
+
id: "OPF-075",
|
|
2446
|
+
severity: "error",
|
|
2447
|
+
message: `Index collection item "${linkHref}" must be an XHTML document`,
|
|
2448
|
+
location: { path: opfPath }
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
if (collection.role === "preview") {
|
|
2454
|
+
for (const linkHref of collection.links) {
|
|
2455
|
+
const manifestItem = this.manifestByHref.get(linkHref);
|
|
2456
|
+
if (!manifestItem) {
|
|
2457
|
+
context.messages.push({
|
|
2458
|
+
id: "OPF-073",
|
|
2459
|
+
severity: "error",
|
|
2460
|
+
message: `Collection link "${linkHref}" references non-existent manifest item`,
|
|
2461
|
+
location: { path: opfPath }
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
1233
2468
|
};
|
|
1234
2469
|
function isSpineMediaType(mediaType) {
|
|
1235
2470
|
return mediaType === "application/xhtml+xml" || mediaType === "image/svg+xml" || // EPUB 2 also allows these in spine
|
|
@@ -1258,6 +2493,442 @@ function resolvePath(basePath, relativePath) {
|
|
|
1258
2493
|
}
|
|
1259
2494
|
return parts.join("/");
|
|
1260
2495
|
}
|
|
2496
|
+
function isValidMimeType(mediaType) {
|
|
2497
|
+
const mimeTypePattern = /^[a-zA-Z][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_+]*(?:\s*;\s*[a-zA-Z0-9-]+=[^;]+)?$/;
|
|
2498
|
+
if (!mimeTypePattern.test(mediaType)) {
|
|
2499
|
+
return false;
|
|
2500
|
+
}
|
|
2501
|
+
const [type, subtypeWithParams] = mediaType.split("/");
|
|
2502
|
+
if (!type || !subtypeWithParams) return false;
|
|
2503
|
+
const subtype = subtypeWithParams.split(";")[0]?.trim();
|
|
2504
|
+
if (!subtype) return false;
|
|
2505
|
+
if (type.length > 127 || subtype.length > 127) {
|
|
2506
|
+
return false;
|
|
2507
|
+
}
|
|
2508
|
+
return true;
|
|
2509
|
+
}
|
|
2510
|
+
function isValidW3CDateFormat(dateStr) {
|
|
2511
|
+
const trimmed = dateStr.trim();
|
|
2512
|
+
const yearOnlyPattern = /^\d{4}$/;
|
|
2513
|
+
if (yearOnlyPattern.test(trimmed)) {
|
|
2514
|
+
const year = Number(trimmed);
|
|
2515
|
+
return year >= 0 && year <= 9999;
|
|
2516
|
+
}
|
|
2517
|
+
const yearMonthPattern = /^(\d{4})-(\d{2})$/;
|
|
2518
|
+
const yearMonthMatch = yearMonthPattern.exec(trimmed);
|
|
2519
|
+
if (yearMonthMatch) {
|
|
2520
|
+
const year = Number(yearMonthMatch[1]);
|
|
2521
|
+
const month = Number(yearMonthMatch[2]);
|
|
2522
|
+
if (year < 0 || year > 9999) return false;
|
|
2523
|
+
if (month < 1 || month > 12) return false;
|
|
2524
|
+
return true;
|
|
2525
|
+
}
|
|
2526
|
+
const dateOnlyPattern = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
2527
|
+
const dateMatch = dateOnlyPattern.exec(trimmed);
|
|
2528
|
+
if (dateMatch) {
|
|
2529
|
+
const year = Number(dateMatch[1]);
|
|
2530
|
+
const month = Number(dateMatch[2]);
|
|
2531
|
+
const day = Number(dateMatch[3]);
|
|
2532
|
+
if (year < 0 || year > 9999) return false;
|
|
2533
|
+
if (month < 1 || month > 12) return false;
|
|
2534
|
+
const daysInMonth = new Date(year, month, 0).getDate();
|
|
2535
|
+
if (day < 1 || day > daysInMonth) return false;
|
|
2536
|
+
return true;
|
|
2537
|
+
}
|
|
2538
|
+
const dateTimePattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/;
|
|
2539
|
+
const dateTimeMatch = dateTimePattern.exec(trimmed);
|
|
2540
|
+
if (dateTimeMatch) {
|
|
2541
|
+
const year = Number(dateTimeMatch[1]);
|
|
2542
|
+
const month = Number(dateTimeMatch[2]);
|
|
2543
|
+
const day = Number(dateTimeMatch[3]);
|
|
2544
|
+
if (year < 0 || year > 9999) return false;
|
|
2545
|
+
if (month < 1 || month > 12) return false;
|
|
2546
|
+
const daysInMonth = new Date(year, month, 0).getDate();
|
|
2547
|
+
if (day < 1 || day > daysInMonth) return false;
|
|
2548
|
+
const hours = Number(dateTimeMatch[4]);
|
|
2549
|
+
const minutes = Number(dateTimeMatch[5]);
|
|
2550
|
+
const seconds = Number(dateTimeMatch[6]);
|
|
2551
|
+
if (hours < 0 || hours > 23) return false;
|
|
2552
|
+
if (minutes < 0 || minutes > 59) return false;
|
|
2553
|
+
if (seconds < 0 || seconds > 59) return false;
|
|
2554
|
+
return true;
|
|
2555
|
+
}
|
|
2556
|
+
return false;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
// src/references/registry.ts
|
|
2560
|
+
var ResourceRegistry = class {
|
|
2561
|
+
resources;
|
|
2562
|
+
ids;
|
|
2563
|
+
constructor() {
|
|
2564
|
+
this.resources = /* @__PURE__ */ new Map();
|
|
2565
|
+
this.ids = /* @__PURE__ */ new Map();
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Register a resource from manifest
|
|
2569
|
+
*/
|
|
2570
|
+
registerResource(resource) {
|
|
2571
|
+
this.resources.set(resource.url, resource);
|
|
2572
|
+
for (const id of resource.ids) {
|
|
2573
|
+
this.registerID(resource.url, id);
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Register an ID in a resource
|
|
2578
|
+
*/
|
|
2579
|
+
registerID(resourceURL, id) {
|
|
2580
|
+
if (!id || !this.resources.has(resourceURL)) {
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
if (!this.ids.has(resourceURL)) {
|
|
2584
|
+
this.ids.set(resourceURL, /* @__PURE__ */ new Set());
|
|
2585
|
+
}
|
|
2586
|
+
const resourceIDs = this.ids.get(resourceURL);
|
|
2587
|
+
resourceIDs?.add(id);
|
|
2588
|
+
const resource = this.resources.get(resourceURL);
|
|
2589
|
+
if (resource) {
|
|
2590
|
+
resource.ids.add(id);
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Get a resource by URL
|
|
2595
|
+
*/
|
|
2596
|
+
getResource(url) {
|
|
2597
|
+
return this.resources.get(url);
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Check if a resource exists
|
|
2601
|
+
*/
|
|
2602
|
+
hasResource(url) {
|
|
2603
|
+
return this.resources.has(url);
|
|
2604
|
+
}
|
|
2605
|
+
/**
|
|
2606
|
+
* Check if an ID exists in a resource
|
|
2607
|
+
*/
|
|
2608
|
+
hasID(resourceURL, id) {
|
|
2609
|
+
const resourceIDs = this.ids.get(resourceURL);
|
|
2610
|
+
return resourceIDs?.has(id) ?? false;
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Get all resources
|
|
2614
|
+
*/
|
|
2615
|
+
getAllResources() {
|
|
2616
|
+
return Array.from(this.resources.values());
|
|
2617
|
+
}
|
|
2618
|
+
/**
|
|
2619
|
+
* Get all resource URLs
|
|
2620
|
+
*/
|
|
2621
|
+
getResourceURLs() {
|
|
2622
|
+
return Array.from(this.resources.keys());
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Get resources in spine
|
|
2626
|
+
*/
|
|
2627
|
+
getSpineResources() {
|
|
2628
|
+
return Array.from(this.resources.values()).filter((r) => r.inSpine);
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Check if a resource is in spine
|
|
2632
|
+
*/
|
|
2633
|
+
isInSpine(url) {
|
|
2634
|
+
const resource = this.resources.get(url);
|
|
2635
|
+
return resource?.inSpine ?? false;
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
|
|
2639
|
+
// src/references/url.ts
|
|
2640
|
+
function parseURL(urlString) {
|
|
2641
|
+
const hashIndex = urlString.indexOf("#");
|
|
2642
|
+
if (hashIndex === -1) {
|
|
2643
|
+
return {
|
|
2644
|
+
url: urlString,
|
|
2645
|
+
resource: urlString,
|
|
2646
|
+
hasFragment: false
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
const resource = urlString.substring(0, hashIndex);
|
|
2650
|
+
const fragment = urlString.substring(hashIndex + 1);
|
|
2651
|
+
const result = {
|
|
2652
|
+
url: urlString,
|
|
2653
|
+
resource,
|
|
2654
|
+
hasFragment: true
|
|
2655
|
+
};
|
|
2656
|
+
if (fragment) {
|
|
2657
|
+
result.fragment = fragment;
|
|
2658
|
+
}
|
|
2659
|
+
return result;
|
|
2660
|
+
}
|
|
2661
|
+
function isDataURL(url) {
|
|
2662
|
+
return url.startsWith("data:");
|
|
2663
|
+
}
|
|
2664
|
+
function isFileURL(url) {
|
|
2665
|
+
return url.startsWith("file:");
|
|
2666
|
+
}
|
|
2667
|
+
function hasAbsolutePath(url) {
|
|
2668
|
+
return url.startsWith("/");
|
|
2669
|
+
}
|
|
2670
|
+
function hasParentDirectoryReference(url) {
|
|
2671
|
+
return url.includes("..");
|
|
2672
|
+
}
|
|
2673
|
+
function isMalformedURL(url) {
|
|
2674
|
+
if (!url) return true;
|
|
2675
|
+
try {
|
|
2676
|
+
if (/[\s<>]/.test(url)) return true;
|
|
2677
|
+
return false;
|
|
2678
|
+
} catch {
|
|
2679
|
+
return true;
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
function isHTTPS(url) {
|
|
2683
|
+
return url.startsWith("https://");
|
|
2684
|
+
}
|
|
2685
|
+
function isHTTP(url) {
|
|
2686
|
+
return url.startsWith("http://");
|
|
2687
|
+
}
|
|
2688
|
+
function isRemoteURL(url) {
|
|
2689
|
+
return isHTTP(url) || isHTTPS(url);
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
// src/references/validator.ts
|
|
2693
|
+
var ReferenceValidator = class {
|
|
2694
|
+
registry;
|
|
2695
|
+
version;
|
|
2696
|
+
references = [];
|
|
2697
|
+
constructor(registry, version) {
|
|
2698
|
+
this.registry = registry;
|
|
2699
|
+
this.version = version;
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Register a reference for validation
|
|
2703
|
+
*/
|
|
2704
|
+
addReference(reference) {
|
|
2705
|
+
this.references.push(reference);
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* Validate all registered references
|
|
2709
|
+
*/
|
|
2710
|
+
validate(context) {
|
|
2711
|
+
for (const reference of this.references) {
|
|
2712
|
+
this.validateReference(context, reference);
|
|
2713
|
+
}
|
|
2714
|
+
this.checkUndeclaredResources(context);
|
|
2715
|
+
}
|
|
2716
|
+
/**
|
|
2717
|
+
* Validate a single reference
|
|
2718
|
+
*/
|
|
2719
|
+
validateReference(context, reference) {
|
|
2720
|
+
const url = reference.url;
|
|
2721
|
+
if (isMalformedURL(url)) {
|
|
2722
|
+
context.messages.push({
|
|
2723
|
+
id: "RSC-020",
|
|
2724
|
+
severity: "error",
|
|
2725
|
+
message: `Malformed URL: ${url}`,
|
|
2726
|
+
location: reference.location
|
|
2727
|
+
});
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
if (isDataURL(url)) {
|
|
2731
|
+
if (this.version.startsWith("3.")) {
|
|
2732
|
+
context.messages.push({
|
|
2733
|
+
id: "RSC-029",
|
|
2734
|
+
severity: "error",
|
|
2735
|
+
message: "Data URLs are not allowed in EPUB 3",
|
|
2736
|
+
location: reference.location
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
if (isFileURL(url)) {
|
|
2742
|
+
context.messages.push({
|
|
2743
|
+
id: "RSC-026",
|
|
2744
|
+
severity: "error",
|
|
2745
|
+
message: "File URLs are not allowed",
|
|
2746
|
+
location: reference.location
|
|
2747
|
+
});
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
const resourcePath = reference.targetResource || parseURL(url).resource;
|
|
2751
|
+
const fragment = reference.fragment ?? parseURL(url).fragment;
|
|
2752
|
+
const hasFragment = fragment !== void 0 && fragment !== "";
|
|
2753
|
+
if (!isRemoteURL(url)) {
|
|
2754
|
+
this.validateLocalReference(context, reference, resourcePath);
|
|
2755
|
+
} else {
|
|
2756
|
+
this.validateRemoteReference(context, reference);
|
|
2757
|
+
}
|
|
2758
|
+
if (hasFragment) {
|
|
2759
|
+
this.validateFragment(context, reference, resourcePath, fragment);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* Validate a local (non-remote) reference
|
|
2764
|
+
*/
|
|
2765
|
+
validateLocalReference(context, reference, resourcePath) {
|
|
2766
|
+
if (hasAbsolutePath(resourcePath)) {
|
|
2767
|
+
context.messages.push({
|
|
2768
|
+
id: "RSC-027",
|
|
2769
|
+
severity: "error",
|
|
2770
|
+
message: "Absolute paths are not allowed in EPUB",
|
|
2771
|
+
location: reference.location
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
const forbiddenParentDirTypes = [
|
|
2775
|
+
"hyperlink" /* HYPERLINK */,
|
|
2776
|
+
"nav-toc-link" /* NAV_TOC_LINK */,
|
|
2777
|
+
"nav-pagelist-link" /* NAV_PAGELIST_LINK */
|
|
2778
|
+
];
|
|
2779
|
+
if (hasParentDirectoryReference(reference.url) && forbiddenParentDirTypes.includes(reference.type)) {
|
|
2780
|
+
context.messages.push({
|
|
2781
|
+
id: "RSC-028",
|
|
2782
|
+
severity: "error",
|
|
2783
|
+
message: "Parent directory references (..) are not allowed",
|
|
2784
|
+
location: reference.location
|
|
2785
|
+
});
|
|
2786
|
+
}
|
|
2787
|
+
if (!this.registry.hasResource(resourcePath)) {
|
|
2788
|
+
const isLinkRef = reference.type === "link" /* LINK */;
|
|
2789
|
+
context.messages.push({
|
|
2790
|
+
id: isLinkRef ? "RSC-007w" : "RSC-007",
|
|
2791
|
+
severity: isLinkRef ? "warning" : "error",
|
|
2792
|
+
message: `Referenced resource not found in manifest: ${resourcePath}`,
|
|
2793
|
+
location: reference.location
|
|
2794
|
+
});
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
const resource = this.registry.getResource(resourcePath);
|
|
2798
|
+
if (reference.type === "hyperlink" /* HYPERLINK */ && !resource?.inSpine) {
|
|
2799
|
+
context.messages.push({
|
|
2800
|
+
id: "RSC-011",
|
|
2801
|
+
severity: "error",
|
|
2802
|
+
message: "Hyperlinks must reference spine items",
|
|
2803
|
+
location: reference.location
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2806
|
+
if (reference.type === "hyperlink" /* HYPERLINK */ || reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */) {
|
|
2807
|
+
const targetMimeType = resource?.mimeType;
|
|
2808
|
+
if (targetMimeType && !this.isBlessedItemType(targetMimeType, context.version) && !this.isDeprecatedBlessedItemType(targetMimeType) && !resource.hasCoreMediaTypeFallback) {
|
|
2809
|
+
context.messages.push({
|
|
2810
|
+
id: "RSC-010",
|
|
2811
|
+
severity: "error",
|
|
2812
|
+
message: "Publication resource references must point to content documents",
|
|
2813
|
+
location: reference.location
|
|
2814
|
+
});
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* Validate a remote reference
|
|
2820
|
+
*/
|
|
2821
|
+
validateRemoteReference(context, reference) {
|
|
2822
|
+
const url = reference.url;
|
|
2823
|
+
if (isHTTP(url) && !isHTTPS(url)) {
|
|
2824
|
+
context.messages.push({
|
|
2825
|
+
id: "RSC-031",
|
|
2826
|
+
severity: "error",
|
|
2827
|
+
message: "Remote resources must use HTTPS",
|
|
2828
|
+
location: reference.location
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
if (isPublicationResourceReference(reference.type)) {
|
|
2832
|
+
const allowedRemoteTypes = /* @__PURE__ */ new Set([
|
|
2833
|
+
"audio" /* AUDIO */,
|
|
2834
|
+
"video" /* VIDEO */,
|
|
2835
|
+
"font" /* FONT */
|
|
2836
|
+
]);
|
|
2837
|
+
if (!allowedRemoteTypes.has(reference.type)) {
|
|
2838
|
+
context.messages.push({
|
|
2839
|
+
id: "RSC-006",
|
|
2840
|
+
severity: "error",
|
|
2841
|
+
message: "Remote resources are only allowed for audio, video, and fonts",
|
|
2842
|
+
location: reference.location
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
/**
|
|
2848
|
+
* Validate a fragment identifier
|
|
2849
|
+
*/
|
|
2850
|
+
validateFragment(context, reference, resourcePath, fragment) {
|
|
2851
|
+
if (!fragment || !this.registry.hasResource(resourcePath)) {
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
const resource = this.registry.getResource(resourcePath);
|
|
2855
|
+
if (reference.type === "stylesheet" /* STYLESHEET */) {
|
|
2856
|
+
context.messages.push({
|
|
2857
|
+
id: "RSC-013",
|
|
2858
|
+
severity: "error",
|
|
2859
|
+
message: "Stylesheet references must not have fragment identifiers",
|
|
2860
|
+
location: reference.location
|
|
2861
|
+
});
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
if (resource?.mimeType === "image/svg+xml") {
|
|
2865
|
+
const hasSVGView = fragment.includes("svgView(") || fragment.includes("viewBox(");
|
|
2866
|
+
if (hasSVGView && reference.type === "hyperlink" /* HYPERLINK */) {
|
|
2867
|
+
context.messages.push({
|
|
2868
|
+
id: "RSC-014",
|
|
2869
|
+
severity: "error",
|
|
2870
|
+
message: "SVG view fragments can only be referenced from SVG documents",
|
|
2871
|
+
location: reference.location
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
if (!this.registry.hasID(resourcePath, fragment)) {
|
|
2876
|
+
context.messages.push({
|
|
2877
|
+
id: "RSC-012",
|
|
2878
|
+
severity: "error",
|
|
2879
|
+
message: `Fragment identifier not found: #${fragment}`,
|
|
2880
|
+
location: reference.location
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
/**
|
|
2885
|
+
* Check for resources that were never referenced
|
|
2886
|
+
* Following Java EPUBCheck OPFChecker30.java logic:
|
|
2887
|
+
* - Skip items in spine (implicitly referenced)
|
|
2888
|
+
* - Skip nav and NCX resources
|
|
2889
|
+
* - Only check for "publication resource references" (not HYPERLINK)
|
|
2890
|
+
*/
|
|
2891
|
+
checkUndeclaredResources(context) {
|
|
2892
|
+
const referencedResources = new Set(
|
|
2893
|
+
this.references.filter((ref) => {
|
|
2894
|
+
if (!isPublicationResourceReference(ref.type)) {
|
|
2895
|
+
return false;
|
|
2896
|
+
}
|
|
2897
|
+
const targetResource = ref.targetResource || parseURL(ref.url).resource;
|
|
2898
|
+
return this.registry.hasResource(targetResource);
|
|
2899
|
+
}).map((ref) => ref.targetResource || parseURL(ref.url).resource)
|
|
2900
|
+
);
|
|
2901
|
+
for (const resource of this.registry.getAllResources()) {
|
|
2902
|
+
if (resource.inSpine) continue;
|
|
2903
|
+
if (referencedResources.has(resource.url)) continue;
|
|
2904
|
+
if (resource.url.includes("nav")) continue;
|
|
2905
|
+
if (resource.url.includes("toc.ncx") || resource.url.includes(".ncx")) continue;
|
|
2906
|
+
if (resource.url.includes("cover-image")) continue;
|
|
2907
|
+
context.messages.push({
|
|
2908
|
+
id: "OPF-097",
|
|
2909
|
+
severity: "usage",
|
|
2910
|
+
message: `Resource declared in manifest but not referenced: ${resource.url}`,
|
|
2911
|
+
location: { path: resource.url }
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* Check if a MIME type is a blessed content document type
|
|
2917
|
+
*/
|
|
2918
|
+
isBlessedItemType(mimeType, version) {
|
|
2919
|
+
if (version === "2.0") {
|
|
2920
|
+
return mimeType === "application/xhtml+xml" || mimeType === "application/x-dtbook+xml";
|
|
2921
|
+
} else {
|
|
2922
|
+
return mimeType === "application/xhtml+xml" || mimeType === "image/svg+xml";
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
/**
|
|
2926
|
+
* Check if a MIME type is a deprecated blessed content document type
|
|
2927
|
+
*/
|
|
2928
|
+
isDeprecatedBlessedItemType(mimeType) {
|
|
2929
|
+
return mimeType === "text/x-oeb1-document" || mimeType === "text/html";
|
|
2930
|
+
}
|
|
2931
|
+
};
|
|
1261
2932
|
var COMPRESSED_SCHEMAS = {
|
|
1262
2933
|
"applications.rng": "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",
|
|
1263
2934
|
"aria.rng": "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",
|
|
@@ -1617,11 +3288,17 @@ var EpubCheck = class _EpubCheck {
|
|
|
1617
3288
|
}
|
|
1618
3289
|
const opfValidator = new OPFValidator();
|
|
1619
3290
|
opfValidator.validate(context);
|
|
3291
|
+
const registry = new ResourceRegistry();
|
|
3292
|
+
const refValidator = new ReferenceValidator(registry, context.version);
|
|
3293
|
+
if (context.packageDocument) {
|
|
3294
|
+
this.populateRegistry(context, registry);
|
|
3295
|
+
}
|
|
1620
3296
|
const contentValidator = new ContentValidator();
|
|
1621
|
-
contentValidator.validate(context);
|
|
3297
|
+
contentValidator.validate(context, registry, refValidator);
|
|
1622
3298
|
if (context.version === "2.0" && context.packageDocument) {
|
|
1623
3299
|
this.validateNCX(context);
|
|
1624
3300
|
}
|
|
3301
|
+
refValidator.validate(context);
|
|
1625
3302
|
const schemaValidator = new SchemaValidator(context);
|
|
1626
3303
|
await schemaValidator.validate();
|
|
1627
3304
|
} catch (error) {
|
|
@@ -1709,6 +3386,25 @@ var EpubCheck = class _EpubCheck {
|
|
|
1709
3386
|
}
|
|
1710
3387
|
messages.push(message);
|
|
1711
3388
|
}
|
|
3389
|
+
/**
|
|
3390
|
+
* Populate resource registry from package document manifest
|
|
3391
|
+
*/
|
|
3392
|
+
populateRegistry(context, registry) {
|
|
3393
|
+
const packageDoc = context.packageDocument;
|
|
3394
|
+
if (!packageDoc) return;
|
|
3395
|
+
const opfPath = context.opfPath ?? "";
|
|
3396
|
+
const opfDir = opfPath.includes("/") ? opfPath.substring(0, opfPath.lastIndexOf("/")) : "";
|
|
3397
|
+
const spineIdrefs = new Set(packageDoc.spine.map((item) => item.idref));
|
|
3398
|
+
for (const item of packageDoc.manifest) {
|
|
3399
|
+
const fullPath = opfDir ? `${opfDir}/${item.href}` : item.href;
|
|
3400
|
+
registry.registerResource({
|
|
3401
|
+
url: fullPath,
|
|
3402
|
+
mimeType: item.mediaType,
|
|
3403
|
+
inSpine: spineIdrefs.has(item.id),
|
|
3404
|
+
ids: /* @__PURE__ */ new Set()
|
|
3405
|
+
});
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
1712
3408
|
};
|
|
1713
3409
|
|
|
1714
3410
|
// src/messages/message-id.ts
|