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