// 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 ' + 'A described photograph ' + ' and a break

' + '

A webp, which epub 3.3 readers must render ' + 'A webp photograph' + 'never resolved

' + '

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 '

back to the results and ' + 'a target that did not survive

' + // 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. '

the third chapter, ' + 'the second chapter, ' + 'a heading it does not have ' + 'and a page nobody saved

' + '

four

' }, { 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
abc
spans twotwo rows tall
a row headercell
' + '
  1. seven
  2. ' + '
  3. forty two
' + '

quoted

' + '
' + '

un paragraphe, ' + 'svp, on ' + '

' + '

A display equation ' + '' + '∫0∞' + 'e−x2' + '=π2' + '

' + '
' }, { 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); })();