save-as-ebook/tests/build-epub.js
2026-08-31 15:06:30 +03:00

348 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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('<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><rect width="4" height="4"/></svg>').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 <ampersands>',
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 <angle brackets> & 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 <img> 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: '<div><h1>A different opening heading</h1>' +
'<p class="a1">Text with an image ' +
'<img src="../images/img-1.png" alt="A described photograph" /> ' +
'<img src="../images/img-1.png" alt="" /> and a break<br /></p>' +
'<p class="a1">A webp, which epub 3.3 readers must render ' +
'<img src="../images/img-4.webp" alt="A webp photograph" />' +
'<img src="../images/img-3.TODO-EXTRACT" alt="never resolved" /></p>' +
'<h2>Background &amp; context</h2><p class="a1">one</p>' +
'<h4>A skipped level</h4><p class="a1">two</p>' +
'<h2 id="results">Results</h2><p class="a1">three</p>' +
// 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
'<p class="a1"><a href="#results">back to the results</a> and ' +
'<a href="#never-extracted">a target that did not survive</a></p>' +
// 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.
'<p class="a1"><a href="https://example.com/three/article#summary">the third chapter</a>, ' +
'<a href="https://example.com/two/">the second chapter</a>, ' +
'<a href="https://example.com/three/article#not-extracted">a heading it does not have</a> ' +
'and <a href="https://example.com/elsewhere">a page nobody saved</a></p>' +
'<h3> </h3><p class="a1">four</p></div>'
},
{
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: '<div><h1>Second chapter — unicode: éèü 你好</h1>' +
// 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
'<table><caption>A table with merged cells</caption>' +
'<colgroup><col span="2" /><col /></colgroup>' +
'<thead><tr><th scope="col">a</th><th scope="col">b</th>' +
'<th scope="col">c</th></tr></thead>' +
'<tbody><tr><td colspan="2">spans two</td>' +
'<td rowspan="2">two rows tall</td></tr>' +
'<tr><th scope="row">a row header</th><td>cell</td></tr></tbody></table>' +
'<ol start="7" reversed="reversed"><li>seven</li>' +
'<li value="42">forty two</li></ol>' +
'<blockquote cite="https://example.com/source"><p class="b2">quoted</p>' +
'</blockquote>' +
'<p class="b2" lang="fr" dir="ltr">un paragraphe, ' +
'<abbr title="s\'il vous plaît">svp</abbr>, on ' +
'<time datetime="2024-03-01">1 mars</time></p>' +
'<p class="b2">A display equation ' +
'<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"' +
' alttext="\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}">' +
'<mrow><msubsup><mo largeop="true" stretchy="true">∫</mo><mn>0</mn><mi>∞</mi></msubsup>' +
'<msup><mi>e</mi><mrow><mo>−</mo><msup><mi>x</mi><mn>2</mn></msup></mrow></msup>' +
'<mo>=</mo><mfrac><msqrt><mi>π</mi></msqrt><mn>2</mn></mfrac></mrow>' +
'</math></p>' +
'<img src="../images/img-2.svg" /></div>'
},
{
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 <html dir> 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 <html> 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 <head>. 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: '<div><h1>الفصل الثالث</h1>' +
'<p class="c3">فقرة عربية بها نص.</p>' +
'<h2 id="summary">ملخص</h2>' +
'<p class="c3">نص الملخص.</p></div>'
}
];
// ---- 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 <Ltd>'}
}));
// 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);
})();