// Builds a .epub from fixture chapters using the extension's real
// saveEbook.js, so what EPUBCheck validates is the file the extension ships -
// not a reimplementation of it.
//
// node build-epub.js [out.epub] [--single] [--chapters file.json]
//
// --single builds a one-chapter book. That is the dominant real case ("save this
// page"), and the package metadata takes different branches for it: dc:date and
// dc:description come from the article itself, which would be a guess to assert
// for a book assembled from several pages.
//
// --chapters builds from a chapter list on disk instead of the fixture below.
// build-edited-epub.sh writes one out of the chapter editor - chapters that were
// edited in a live DOM and serialized back - so that EPUBCheck sees the content
// an edit produces and not only the content extraction produces.
//
// saveEbook.js expects a browser: the handful of globals it touches are stubbed
// below, and downloadBlob() is swapped for a write to disk.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const EXT = path.join(__dirname, '..', 'web-extension');
const args = process.argv.slice(2);
const single = args.indexOf('--single') > -1;
const chaptersFlag = args.indexOf('--chapters');
const chaptersFile = chaptersFlag > -1 ? args[chaptersFlag + 1] : null;
// the first argument that is not a flag, and not the file name belonging to one
const outFile = args.filter((a, i) => a !== '--single' && a !== '--chapters' &&
!(chaptersFlag > -1 && i === chaptersFlag + 1))[0] ||
path.join(__dirname, 'out', 'fixture.epub');
// 1x1 png
const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
const SVG = Buffer.from('').toString('base64');
// 1x1 lossless webp - a core image type since epub 3.3, so it goes in unchanged
const WEBP_1X1 = 'UklGRhwAAABXRUJQVlA4TA8AAAAvAAAAEAcQ/Y/+BSKi/wEA';
const chapters = [
{
title: 'First chapter & its ',
url: 'chapter0.xhtml',
baseUrl: 'https://example.com/one/',
sourceUrl: 'https://example.com/one/article?id=1&ref=x',
metadata: {
lang: 'en-US',
// two authors: one invertible into a file-as, one an organisation
// that must not be ("News, BBC")
authors: ['Jane Smith', 'BBC News'],
publisher: 'Example & Co',
description: 'A description with & an ampersand.',
date: '2024-03-01'
},
// Junk typed into the editor for this chapter. Both values are shaped
// like the thing they claim to be and neither is one: "1" is not a
// script, a region or a variant, and February has no 31st. Nothing here
// may reach the package document - the page's own en-US and 2024-03-01
// are what the checks below expect - and this is the one-chapter book,
// so this chapter is where dc:language and dc:date come from. Before
// they were range-checked these produced OPF-092 and OPF-053.
metadataOverride: {
lang: 'en-1',
authors: [],
publisher: '',
description: '',
date: '2024-02-31'
},
styleFileName: 'style0.css',
styleFileContent: '.a1 {color:rgb(0, 0, 0);font-size:12px;}',
// Css the user wrote for this one chapter, alongside the book-wide
// stylesheet passed to buildEbook() below. Both are here so that
// EPUBCheck sees them: user-authored css is deliberately not held to the
// allowlist extraction uses, and this is what says that a book carrying
// some is still a valid book.
customCss: '.a1 {text-align: justify;}\n' +
// and that what does escape a stylesheet is taken out before
// it can contradict a manifest that claims no remote resources
'.a1 {background-image: url("https://cdn.example.com/paper.png");}\n' +
'@import url("https://cdn.example.com/more.css");\n' +
// A reference to a file nobody downloaded. Not remote, so the
// remote rule has nothing to say about it, and it lands in
// OEBPS/style/ where there is no such file: RSC-007, which
// fails the package over a background image. This is the
// audit's own reproduction, kept here so the next one shows
// up as an EPUBCheck error in the normal suite.
'.a1 {background-image: url(missing.png);}\n' +
// And the mistake beside it, which has a picture behind it:
// the chapter names images/img-1.png the way it reads in the
// chapter's own markup, but this file is a folder deeper. It
// has to reach the same image rather than be dropped with the
// one above.
'.a1 {list-style-image: url(images/img-1.png);}',
// img-3 is the chapter a version that could not type a webp left in
// storage: an image with no resolvable media type, which cannot be
// declared in the manifest. It and the below pointing at it must
// both be gone from the built book - either one surviving alone is
// invalid, and the pair of them is what an upgrade actually finds.
images: [{filename: 'img-1.png', data: PNG_1X1},
{filename: 'img-4.webp', data: WEBP_1X1},
{filename: 'img-3.TODO-EXTRACT', data: WEBP_1X1}],
// a described image and a decorative one: both are answers, so this
// chapter alone would support the alternativeText claim
// A heading structure that does not match its nesting: h2, then h4 (a
// level the page skipped), then back to h2. Also an empty heading, which
// cannot be linked to, and one carrying an id of its own.
content: '
A different opening heading
' +
'
Text with an image ' +
' ' +
' and a break
' +
'
A webp, which epub 3.3 readers must render ' +
'' +
'
' +
'
Background & context
one
' +
'
A skipped level
two
' +
'
Results
three
' +
// one link into the chapter that resolves and one that does
// not: the target of the second is the kind of element that
// never leaves extraction, and a fragment pointing at nothing
// is an epubcheck error rather than a dead link
'
' +
// Links to other pages of the same site, as extraction leaves
// them - absolute, because while this chapter was extracted the
// rest of the book did not exist. Two of them are chapters here
// and have to end up pointing at those files; the third is a
// page nobody saved and has to be left addressing the web.
// The second chapter is addressed by the url its baseUrl names,
// since it predates sourceUrl entirely.
'
'
},
{
title: 'Second chapter — unicode: éèü 你好',
url: 'chapter1.xhtml',
// no sourceUrl: a chapter buffered by a version that predates it, which
// has to fall back to baseUrl rather than lose dc:source
baseUrl: 'https://example.com/two/',
// a page that stated a different language, and junk in the fields that
// must not reach the package document
metadata: {
lang: 'ZH_hans_cn',
authors: ['李雷'],
publisher: '',
description: '',
date: '2024-04-02T09:30:00+02:00'
},
styleFileName: 'style1.css',
styleFileContent: '.b2 {margin:0;}',
images: [{filename: 'img-2.svg', data: SVG}],
// an image whose source page said nothing about it - which must pull the
// whole book's alternativeText claim, and turn the svg into something
// that might animate
// opens with a heading that is the page title: the chapter must not end
// up with the title printed and listed twice
//
// ...and it is the only chapter with MathML, so properties="mathml" has
// to appear on this manifest item and on no other
content: '
Second chapter — unicode: éèü 你好
' +
// the structural attributes, in a shape EPUBCheck has to
// accept: a merged cell renders as an unmerged one without
// them, so every row after it is short
'
A table with merged cells
' +
'
' +
'
a
b
' +
'
c
' +
'
spans two
' +
'
two rows tall
' +
'
a row header
cell
' +
'
seven
' +
'
forty two
' +
'
quoted
' +
'
' +
'
un paragraphe, ' +
'svp, on ' +
'
' +
'
A display equation ' +
'
' +
'
'
},
{
title: 'الفصل الثالث',
url: 'chapter2.xhtml',
baseUrl: 'https://example.com/three/',
sourceUrl: 'https://example.com/three/article',
metadata: {
lang: 'ar',
// What the page said about which way it reads. Whether it said it
// with or in css, extraction reports it the same way -
// and this is the only value it ever reports, so the book turns its
// pages right to left and this chapter's carries dir.
dir: 'rtl',
authors: [],
publisher: '',
description: '',
date: ''
},
// What a user typed about this chapter in the editor, over a page that
// said nothing: a creator and a date that reach the package document
// from the panel rather than from any . The name is one getFileAs()
// must decline to invert, so the refinement it does get is a role and
// nothing more.
metadataOverride: {
lang: '',
authors: ['أحمد الشيخ'],
publisher: '',
description: '',
date: '1998-05-04'
},
styleFileName: 'style2.css',
styleFileContent: '.c3 {color:rgb(0, 0, 0);}',
// no images and no formulas: this chapter is here for direction and for
// being a link target, and it must not move the book's accessibility
// claims while it is
images: [],
content: '
الفصل الثالث
' +
'
فقرة عربية بها نص.
' +
'
ملخص
' +
'
نص الملخص.
'
}
];
// ---- browser stubs ----------------------------------------------------------
const sandbox = {
console: console,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
setInterval: () => 0,
clearInterval: () => {},
Date: Date,
Math: Math,
crypto: crypto,
JSON: JSON,
Promise: Promise,
Blob: Blob,
// the real constructor, which chapterUrlKey() parses addresses with, plus
// the two statics the download path calls
URL: Object.assign(class extends URL {}, {
createObjectURL: () => 'blob:stub',
revokeObjectURL: () => {}
}),
Uint8Array: Uint8Array,
ArrayBuffer: ArrayBuffer,
String: String,
Object: Object,
Array: Array,
Error: Error,
chrome: {
runtime: {
onMessage: {addListener: () => {}},
sendMessage: () => {},
lastError: null
},
i18n: {getMessage: (k) => k},
storage: {local: {get: (k, cb) => cb({}), set: () => {}}}
},
document: {
title: 'fixture',
createElement: () => ({style: {}, click: () => {}, appendChild: () => {}}),
body: {appendChild: () => {}, removeChild: () => {}},
documentElement: {appendChild: () => {}, removeChild: () => {}}
},
window: {location: {href: 'https://example.com/', origin: 'https://example.com'}},
navigator: {userAgent: 'node'}
};
sandbox.window.document = sandbox.document;
sandbox.self = sandbox.window;
vm.createContext(sandbox);
function load(file) {
const code = fs.readFileSync(path.join(EXT, file), 'utf8');
vm.runInContext(code, sandbox, {filename: file});
}
load('libs/jszip.js');
// the UMD wrapper attaches to `window` when there is one, which in a vm context
// is not the same object as the sandbox global
if (!sandbox.JSZip && sandbox.window.JSZip) {
sandbox.JSZip = sandbox.window.JSZip;
}
load('utils.js');
load('cssSanitizer.js');
load('saveEbook.js');
// ---- build ------------------------------------------------------------------
let built = null;
sandbox.downloadBlob = function (blob, fileName) {
built = {blob: blob, fileName: fileName};
};
let toBuild = chapters;
if (chaptersFile) {
toBuild = JSON.parse(fs.readFileSync(chaptersFile, 'utf8'));
if (!Array.isArray(toBuild) || toBuild.length === 0) {
console.error('FAILED: ' + chaptersFile + ' holds no chapters');
process.exit(1);
}
console.log('building from ' + chaptersFile + ' (' + toBuild.length + ' chapters)');
}
// The book metadata a user stated in the editor. Only the publisher: the fields
// beside it are what the fixture chapters are here to exercise - dc:date and
// dc:description branch on how many chapters there are, dc:language is read off
// the pages, and dc:creator is where getFileAs() has to decide what it can
// invert. Overriding those would replace the cases with a constant. The
// publisher is stated with characters that have to be escaped, which is the one
// thing a stated value can do to a package that a derived one cannot.
vm.runInContext('buildEbook(FIXTURE, {title: "Fixture Book", css: BOOK_CSS, metadata: BOOK_META})',
Object.assign(sandbox, {
FIXTURE: single ? toBuild.slice(0, 1) : toBuild,
// The same two references from the other depth: ebook.css is
// beside images/, so the prefix that is right one folder
// down is wrong here, and a book stylesheet may name a
// picture that arrived with any chapter.
BOOK_CSS: 'body {\n font-family: serif;\n line-height: 1.5;\n}\n' +
'blockquote {\n font-style: italic;\n}\n' +
'ul {\n list-style-image: url("../images/img-1.png");\n}\n' +
'.nowhere {\n background-image: url(no-such-file.png);\n}\n',
BOOK_META: {publisher: 'A & B Books '}
}));
// buildEbook finishes asynchronously in generateAsync
const deadline = Date.now() + 30000;
(function wait() {
if (built) {
built.blob.arrayBuffer().then((buf) => {
fs.mkdirSync(path.dirname(outFile), {recursive: true});
fs.writeFileSync(outFile, Buffer.from(buf));
console.log('wrote ' + outFile + ' (' + Buffer.from(buf).length + ' bytes)');
});
return;
}
if (Date.now() > deadline) {
console.error('FAILED: buildEbook never produced a file');
process.exit(1);
}
setTimeout(wait, 50);
})();