start working on chapter editor

This commit is contained in:
alexadam 2026-08-07 21:05:16 +03:00
parent c1a93e7591
commit a16bf344e3
33 changed files with 3997 additions and 533 deletions

View file

@ -303,9 +303,11 @@ async function test(name, body) {
await settle();
assert.strictEqual(absentResult, true);
// Order is the assertion, not just membership: jszip and readability are
// globals the scripts after them use at load time.
// globals the scripts after them use at load time, and sanitizeHtml.js
// defines the tag lists extractHtml.js reads while it loads.
assert.deepStrictEqual(absent.state.executeScripts[0].files,
['libs/jszip.js', 'libs/readability.js', 'utils.js', 'extractHtml.js', 'saveEbook.js']);
['libs/jszip.js', 'libs/readability.js', 'utils.js', 'sanitizeHtml.js',
'extractHtml.js', 'saveEbook.js']);
const denied = createHarness({pingResponse: undefined, executeScriptFailure: true});
let deniedResult;
@ -431,10 +433,27 @@ async function test(name, body) {
assert.deepStrictEqual(request(h, {type: 'get uuid'}), [{uuid: 'second-id'}]);
});
await test('the book stylesheet persists for a book and is discarded with it', () => {
const h = createHarness();
assert.deepStrictEqual(request(h, {type: 'get book css'}), [{css: ''}],
'a book nobody styled has an empty stylesheet, not a missing one');
request(h, {type: 'set book css', css: 'body{font-family:serif}'});
assert.deepStrictEqual(request(h, {type: 'get book css'}),
[{css: 'body{font-family:serif}'}]);
request(h, {type: 'remove'});
assert.strictEqual(h.state.local.bookCss, undefined,
'the stylesheet belongs to the discarded chapters');
// the per-site styles are a different thing entirely and outlive any book
request(h, {type: 'set styles', styles: [{title: 'a site'}]});
request(h, {type: 'remove'});
assert.deepStrictEqual(h.state.local.styles, [{title: 'a site'}]);
});
await test('each runtime message sends at most one response', async () => {
const cases = [
{type: 'get'}, {type: 'set', pages: []}, {type: 'remove'},
{type: 'get uuid'}, {type: 'get title'}, {type: 'set title', title: 'T'},
{type: 'get book css'}, {type: 'set book css', css: 'p{}'},
{type: 'get styles'}, {type: 'set styles', styles: []},
{type: 'get current style'}, {type: 'set current style', currentStyle: 2},
{type: 'get include style'}, {type: 'set include style', includeStyle: true},

48
tests/build-edited-epub.sh Executable file
View file

@ -0,0 +1,48 @@
#!/bin/bash
# Builds a book out of chapters that were edited in the chapter editor, so that
# EPUBCheck sees content an edit produced.
#
# The editing happens in a real DOM - contentEditable, element removal and the
# serialization back out of it are all browser behaviour, and a shim would be
# testing the shim - so the chapters come from the headless run of
# dom/chapter-editing.html, which writes them into the page as JSON. This script
# lifts them back out of the dumped dom and hands them to the same builder the
# extension ships.
#
# The whole risk of the editing feature is emitting XHTML a reading system
# cannot parse. This is the step that would notice.
set -u
cd "$(dirname "$0")" || exit 1
CHROME="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
if [ ! -x "$CHROME" ]; then
CHROME="$(command -v google-chrome || command -v chromium || true)"
fi
if [ -z "$CHROME" ] || [ ! -x "$CHROME" ]; then
echo "SKIP: no Chrome found. Set CHROME=/path/to/chrome" >&2
exit 0
fi
mkdir -p out
dom=$("$CHROME" --headless --disable-gpu --no-sandbox \
--allow-file-access-from-files --virtual-time-budget=8000 \
--dump-dom "file://$PWD/dom/chapter-editing.html" 2>/dev/null)
# One line, written by exportSavedChapters(). The dump is html, so the three
# characters the serializer escapes have to be put back before it is json again.
json=$(echo "$dom" \
| grep -o '<pre id="edited-chapters">.*</pre>' \
| sed -e 's/^<pre id="edited-chapters">//' -e 's|</pre>$||' \
-e 's/&lt;/</g' -e 's/&gt;/>/g' -e 's/&amp;/\&/g')
if [ -z "$json" ]; then
echo "FAILED: the editing fixture produced no saved chapters"
exit 1
fi
printf '%s' "$json" > out/edited-chapters.json
node build-epub.js out/edited.epub --chapters out/edited-chapters.json || exit 1
./run-epubcheck.sh out/edited.epub

View file

@ -2,13 +2,18 @@
// saveEbook.js, so what EPUBCheck validates is the file the extension ships -
// not a reimplementation of it.
//
// node build-epub.js [out.epub] [--single]
// 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.
@ -19,7 +24,11 @@ const vm = require('vm');
const EXT = path.join(__dirname, '..', 'web-extension');
const args = process.argv.slice(2);
const single = args.indexOf('--single') > -1;
const outFile = args.filter((a) => a !== '--single')[0] ||
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
@ -45,6 +54,16 @@ const chapters = [
},
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");',
// 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
@ -239,11 +258,24 @@ sandbox.downloadBlob = function (blob, fileName) {
built = {blob: blob, fileName: fileName};
};
sandbox.ebookTitle = 'Fixture Book';
vm.runInContext('_buildEbook(FIXTURE)',
Object.assign(sandbox, {FIXTURE: single ? chapters.slice(0, 1) : chapters}));
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)');
}
// _buildEbook finishes asynchronously in generateAsync
vm.runInContext('buildEbook(FIXTURE, {title: "Fixture Book", css: BOOK_CSS})',
Object.assign(sandbox, {
FIXTURE: single ? toBuild.slice(0, 1) : toBuild,
BOOK_CSS: 'body {\n font-family: serif;\n line-height: 1.5;\n}\n' +
'blockquote {\n font-style: italic;\n}\n'
}));
// buildEbook finishes asynchronously in generateAsync
const deadline = Date.now() + 30000;
(function wait() {
if (built) {
@ -255,7 +287,7 @@ const deadline = Date.now() + 30000;
return;
}
if (Date.now() > deadline) {
console.error('FAILED: _buildEbook never produced a file');
console.error('FAILED: buildEbook never produced a file');
process.exit(1);
}
setTimeout(wait, 50);

View file

@ -8,6 +8,7 @@ window.chrome = {
};
</script>
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/extractHtml.js"></script>
<base href="https://example.com/blog/">
<style>

View file

@ -8,6 +8,7 @@ window.chrome = {
};
</script>
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/extractHtml.js"></script>
<base href="https://example.com/blog/">
</head>

View file

@ -0,0 +1,469 @@
<!doctype html>
<html>
<head>
<!-- a file:// document with no charset is read as windows-1252, and this
fixture's content is deliberately not all ascii -->
<meta charset="utf-8">
<link rel="stylesheet" href="../../web-extension/chapterEditor.css">
</head>
<body>
<script>
// Editing a chapter in the frame that previews it. The assertions that matter
// are the last group: whatever is done to the live DOM, what reaches storage has
// to be a chapter the build stage would accept - well formed, sanitized, and
// carrying no image record nothing points at.
var PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
var FIXTURE_PAGES = [
{
title: 'Chapter One',
url: 'c1.xhtml',
// An aside standing in for the thing a reader wants gone - a wrapper whose
// own children are what the pointer lands on, carrying an image of its own.
//
// The rest is here to be carried through the round trip rather than to be
// edited: a table, a list with counters, a formula, links into the chapter
// and out of it, escaped text. All of it is written into a live DOM,
// serialized back out of one, and then validated by EPUBCheck - the
// structures where an editing round trip could plausibly emit something a
// reading system cannot parse.
content: '<p id="keep">kept text</p>' +
'<aside class="ad"><p>advert</p>' +
'<img src="../images/img-2.png" alt="ad" /></aside>' +
'<p><img src="../images/img-1.png" alt="figure" /></p>' +
'<h2 id="sec">A section &amp; its heading</h2>' +
'<table><caption>A table</caption>' +
'<colgroup><col span="2" /></colgroup>' +
'<thead><tr><th scope="col">a</th><th scope="col">b</th></tr></thead>' +
'<tbody><tr><td colspan="2">spans two</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>quoted</p></blockquote>' +
'<p lang="fr" dir="ltr">un paragraphe, ' +
'<abbr title="s\'il vous plaît">svp</abbr>, le ' +
'<time datetime="2024-03-01">1 mars</time></p>' +
'<p>A formula <math xmlns="http://www.w3.org/1998/Math/MathML" ' +
'display="block" alttext="x^2"><mrow><msup><mi>x</mi><mn>2</mn></msup>' +
'</mrow></math></p>' +
'<p><a href="#sec">into the chapter</a> and ' +
'<a href="https://example.com/elsewhere">out of it</a></p>',
images: [
{filename: 'img-1.png', data: PNG_1X1},
{filename: 'img-2.png', data: PNG_1X1}
],
styleFileName: 'style0.css',
styleFileContent: ''
},
{
title: 'Chapter Two',
url: 'c2.xhtml',
content: '<h1>Chapter Two</h1><p>two</p>',
images: [],
styleFileName: 'style1.css',
styleFileContent: ''
}
];
var written = {pages: null};
var closedTab = false;
window.close = function(){ closedTab = true; };
// The editor asks before discarding edits; the answer is the fixture's to give
var confirmCalls = 0;
var confirmAnswer = true;
window.confirm = function(){ confirmCalls++; return confirmAnswer; };
window.chrome = {
runtime: {
onMessage: {addListener: function(){}},
lastError: null,
getURL: function(p){ return 'chrome-extension://fake/' + p; },
sendMessage: function(msg, cb) {
var reply = {};
if (msg.type === 'set') written.pages = msg.pages;
if (msg.type === 'get') reply = {allPages: FIXTURE_PAGES};
if (msg.type === 'get title') reply = {title: 'My Book'};
if (msg.type === 'get uuid') reply = {uuid: 'fixture-uuid'};
if (cb) setTimeout(function(){ cb(reply); }, 0);
}
},
i18n: {getMessage: function(k){ return k; }},
tabs: {query: function(q, cb){ cb([{id: 1, url: 'https://example.com'}]); },
create: function(){}, update: function(){}},
commands: {getAll: function(cb){ cb([]); }}
};
var errors = [];
window.addEventListener('error', function(e){ errors.push(e.message); });
</script>
<!-- the script set chapters.html loads, in the same order -->
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/libs/jszip.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/saveEbook.js"></script>
<script src="../../web-extension/chapterPreview.js"></script>
<script src="../../web-extension/chapterContentEditor.js"></script>
<script src="../../web-extension/chapterEditor.js"></script>
<script>
var results = document.createElement('ul');
results.id = 'results';
document.body.appendChild(results);
function detail(text) {
var line = String(text).replace(/\s+/g, ' ').trim();
return line.length > 200 ? line.substring(0, 200) + '...' : line;
}
function report(name, ok, why) {
var li = document.createElement('li');
li.textContent = (ok ? 'PASS ' : 'FAIL ') + name + (why ? ' -- ' + detail(why) : '');
results.appendChild(li);
}
function done(message) {
if (message) {
report(message, false);
}
document.title = 'DONE';
}
// The chapters as they were saved, for build-edited-epub.sh to lift out of the
// dumped dom and build a real book from. Asserting that edited content is well
// formed is this fixture's job; whether a book made of it is valid is EPUBCheck's,
// and nothing here can answer that on its own.
function exportSavedChapters(pages) {
var out = document.createElement('pre');
out.id = 'edited-chapters';
// one line, because the runner reads the dumped dom a line at a time
out.textContent = JSON.stringify(pages);
document.body.appendChild(out);
}
// The fixture drives a real frame, so every step after the first waits for one
function waitFor(what, predicate, next) {
var tries = 0;
(function poll() {
var ready = false;
try { ready = predicate(); } catch (e) { ready = false; }
if (ready) { next(); return; }
if (++tries > 100) { done('timed out waiting for ' + what); return; }
setTimeout(poll, 20);
})();
}
function button(id) {
return document.getElementById(id);
}
var frame = null;
var frameDoc = null;
var editRoot = null;
var savedChapters = null;
// Events raised the way a browser raises them: on the element, bubbling up to
// the document listeners the session installs there.
function fire(node, type, init) {
var win = frame.contentWindow;
var Constructor = (type === 'mousemove' || type === 'click') ? win.MouseEvent :
(type === 'beforeinput' ? win.InputEvent : win.Event);
node.dispatchEvent(new Constructor(type,
Object.assign({bubbles: true, cancelable: true}, init || {})));
}
function hovered() {
return frameDoc.querySelector('.sae-edit-hover');
}
function hint() {
return document.getElementById('chapterEditor-previewHint').innerText;
}
// Where the caret is decides where an inserted paragraph lands
function caretIn(element) {
var range = frameDoc.createRange();
range.selectNodeContents(element);
range.collapse(true);
var selection = frameDoc.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
setTimeout(function () {
report('scripts ran without throwing', errors.length === 0, errors.join(' | '));
document.getElementById('li0').getElementsByTagName('button')[0].click();
frame = document.getElementById('chapterEditor-previewFrame');
waitFor('the frame to load and the session to start',
function() { return !button('chapterEditor-modeRemove').disabled; },
afterLoad);
}, 300);
function afterLoad() {
frameDoc = frame.contentDocument;
editRoot = frameDoc.querySelector('[data-sae-edit-root]');
// ---- what is editable, and what is the build's ----------------------------
report('the editing session reaches into the sandboxed frame', !!frameDoc);
report('the chapter content is wrapped in an editable container', !!editRoot);
report('the content is all of it',
!!editRoot && editRoot.querySelectorAll('aside').length === 1 &&
editRoot.querySelectorAll('table, ol, blockquote, math').length === 4,
editRoot ? String(editRoot.children.length) + ' top level elements' : '');
// the heading the build adds is regenerated from the chapter title on every
// build, so it is left outside the part the user may change
var generatedHeading = frameDoc.querySelector('h1');
report('the heading the build adds is outside the editable container',
!!generatedHeading && generatedHeading.textContent === 'Chapter One' &&
!editRoot.contains(generatedHeading));
report('the toolbar starts in view mode',
button('chapterEditor-modeView').classList.contains('chapterEditor-mode-active'));
report('nothing to save before anything is edited',
button('chapterEditor-saveChapter').disabled &&
button('chapterEditor-undo').disabled);
// ---- remove mode -----------------------------------------------------------
button('chapterEditor-modeRemove').click();
report('remove mode is what the toolbar says it is',
button('chapterEditor-modeRemove').classList.contains('chapterEditor-mode-active') &&
!button('chapterEditor-modeView').classList.contains('chapterEditor-mode-active'));
var advert = editRoot.querySelector('aside > p');
fire(advert, 'mousemove');
report('hovering highlights the element under the pointer', hovered() === advert,
hovered() ? hovered().tagName : 'nothing highlighted');
report('the toolbar names what a click would take', hint().indexOf('<p>') > -1, hint());
// what a reader wants gone is the wrapper, not the paragraph the pointer
// happens to be over - the widening selector.js does on a live page
button('chapterEditor-widen').click();
report('select parent widens the target to the block around it',
hovered() === editRoot.querySelector('aside'),
hovered() ? hovered().tagName : 'nothing highlighted');
report('the widened target is what the toolbar names',
hint().indexOf('<aside.ad>') > -1, hint());
button('chapterEditor-narrow').click();
fire(advert, 'mousemove');
report('select child walks back down', hovered() === advert,
hovered() ? hovered().tagName : 'nothing highlighted');
button('chapterEditor-widen').click();
var paragraphsBefore = editRoot.querySelectorAll('p').length;
fire(editRoot.querySelector('aside'), 'click');
report('clicking removes the highlighted element, and what was inside it',
editRoot.querySelectorAll('aside').length === 0 &&
editRoot.querySelectorAll('p').length === paragraphsBefore - 1,
editRoot.querySelectorAll('p').length + ' paragraphs left of ' + paragraphsBefore);
report('the removal is undoable and savable',
!button('chapterEditor-undo').disabled &&
!button('chapterEditor-saveChapter').disabled);
report('nothing was written to storage by the removal itself',
written.pages === null);
// ---- undo ------------------------------------------------------------------
button('chapterEditor-undo').click();
report('undo brings the removed element back',
editRoot.querySelectorAll('aside').length === 1 &&
editRoot.querySelectorAll('p').length === paragraphsBefore,
editRoot.querySelectorAll('p').length + ' paragraphs');
report('and the rest of the chapter with it, unchanged',
editRoot.querySelectorAll('table, ol, blockquote, math').length === 4 &&
!!editRoot.querySelector('a[href="#sec"]'));
report('the image inside it is rendered again, not left as a reference',
(editRoot.querySelector('aside img') || {src: ''}).src.indexOf('data:image/png;base64,') === 0,
(editRoot.querySelector('aside img') || {src: 'no image'}).src.substring(0, 30));
report('there is nothing left to undo', button('chapterEditor-undo').disabled);
// ...and away again, which is the state the save below is of
fire(editRoot.querySelector('aside > p'), 'mousemove');
button('chapterEditor-widen').click();
fire(editRoot.querySelector('aside'), 'click');
report('the element goes again', editRoot.querySelectorAll('aside').length === 0);
// the heading the build writes is not the user's to remove
button('chapterEditor-modeView').click();
button('chapterEditor-modeRemove').click();
fire(frameDoc.querySelector('h1'), 'mousemove');
report('the generated heading cannot be hovered', hovered() === null);
fire(frameDoc.querySelector('h1'), 'click');
report('and cannot be clicked away', !!frameDoc.querySelector('h1'));
// ---- text mode -------------------------------------------------------------
button('chapterEditor-modeText').click();
report('text mode makes the chapter editable',
editRoot.getAttribute('contenteditable') === 'true');
report('the generated heading is still not editable',
frameDoc.querySelector('h1').isContentEditable === false);
// An inserted paragraph goes beside the block the caret is in, not at the end
// of the chapter - "add a note here" is the whole point of it.
caretIn(editRoot.querySelector('#keep'));
button('chapterEditor-insertAfter').click();
var added = editRoot.querySelector('#keep').nextElementSibling;
report('a paragraph is added after the block the caret is in',
added.tagName === 'P' && added.textContent === 'insertedParagraphText',
added.tagName + ' "' + added.textContent + '"');
report('and the caret moves into it, with its placeholder selected so that '
+ 'typing replaces it',
String(frameDoc.getSelection()).trim() === 'insertedParagraphText',
'"' + String(frameDoc.getSelection()) + '"');
var figure = editRoot.lastElementChild;
caretIn(figure);
button('chapterEditor-insertBefore').click();
report('and before it when that is what was asked for',
figure.previousElementSibling.textContent === 'insertedParagraphText',
figure.previousElementSibling.outerHTML);
// the one that was just inserted goes again, so that the chapter saved below
// holds exactly one added paragraph
figure.previousElementSibling.remove();
// typing, as the browser raises it: the event first, then the change
var kept = editRoot.querySelector('#keep');
fire(kept, 'beforeinput', {inputType: 'insertText', data: 'x'});
kept.textContent = 'edited text';
report('typing is one undo step, snapshotted before the run',
!button('chapterEditor-undo').disabled);
// ---- saving ----------------------------------------------------------------
button('chapterEditor-saveChapter').click();
waitFor('the chapter to be written', function() { return written.pages !== null; },
afterSave);
}
function afterSave() {
savedChapters = written.pages;
var saved = written.pages[0];
report('the edited chapter is what was written', !!saved && saved.title === 'Chapter One');
// The whole risk of this feature is emitting content the build stage cannot
// read back, so this is the assertion the others exist to support.
var xml = new DOMParser().parseFromString(
'<html xmlns="http://www.w3.org/1999/xhtml"><body>' + saved.content + '</body></html>',
'application/xhtml+xml');
var parseError = xml.querySelector('parsererror');
report('what was stored is well formed xml', !parseError,
parseError ? parseError.textContent : '');
report('and is a fixed point of the sanitizer, so it round trips',
sanitizeChapterContent(saved.content) === saved.content,
saved.content);
report('the removed element is gone from the stored chapter',
saved.content.indexOf('advert') < 0 && saved.content.indexOf('<aside') < 0,
saved.content);
report('the edited text is in it', saved.content.indexOf('edited text') > -1, saved.content);
report('so is the inserted paragraph',
saved.content.indexOf('<p>insertedParagraphText</p>') > -1, saved.content);
// what the frame renders is not what a chapter holds
report('no data url reached storage', saved.content.indexOf('data:image') < 0);
report('the surviving image points into the archive again',
saved.content.indexOf('src="../images/img-1.png"') > -1, saved.content);
report('no editor class was left on the content',
saved.content.indexOf('sae-edit') < 0 && saved.content.indexOf('class=""') < 0,
saved.content);
report('and neither was the editable container',
saved.content.indexOf('contenteditable') < 0 &&
saved.content.indexOf('data-sae-edit-root') < 0);
// an image nothing points at is a manifest item for a file no reader draws
report('the image removed with its element is pruned from the chapter',
saved.images.length === 1 && saved.images[0].filename === 'img-1.png',
saved.images.map(function(i){ return i.filename; }).join(','));
report('the other chapter was written beside it, unchanged',
written.pages.length === 2 && written.pages[1].content === '<h1>Chapter Two</h1><p>two</p>');
report('there is nothing left to save', button('chapterEditor-saveChapter').disabled);
// ---- closing ---------------------------------------------------------------
confirmCalls = 0;
document.getElementById('chapterEditor-previewHeader')
.getElementsByTagName('button')[0].click();
report('a saved chapter closes without asking',
confirmCalls === 0 && document.getElementById('chapterEditor-previewPanel')
.style.display === 'none');
report('the frame goes with the panel',
document.getElementsByTagName('iframe').length === 0);
// ...and an unsaved one does ask
document.getElementById('li1').getElementsByTagName('button')[0].click();
frame = document.getElementById('chapterEditor-previewFrame');
waitFor('the second chapter to load',
function() { return !button('chapterEditor-modeRemove').disabled; },
afterReopen);
}
function afterReopen() {
frameDoc = frame.contentDocument;
editRoot = frameDoc.querySelector('[data-sae-edit-root]');
// this chapter opens with a heading naming itself, so the build adds none and
// the page's own <h1> is the user's to edit
report('a chapter that names itself has its heading inside the editable part',
!!editRoot && editRoot.querySelectorAll('h1').length === 1,
editRoot ? editRoot.innerHTML : '');
button('chapterEditor-modeRemove').click();
fire(editRoot.querySelector('p'), 'mousemove');
fire(editRoot.querySelector('p'), 'click');
report('the second chapter has an unsaved edit',
editRoot.querySelectorAll('p').length === 0 &&
!button('chapterEditor-saveChapter').disabled);
confirmCalls = 0;
confirmAnswer = false;
document.getElementById('chapterEditor-previewHeader')
.getElementsByTagName('button')[0].click();
report('closing with unsaved edits asks first', confirmCalls === 1);
report('and answering no keeps the panel open',
document.getElementById('chapterEditor-previewPanel').style.display === 'block' &&
!!document.getElementById('chapterEditor-previewFrame'));
confirmAnswer = true;
document.getElementById('chapterEditor-previewHeader')
.getElementsByTagName('button')[0].click();
report('answering yes discards them',
document.getElementById('chapterEditor-previewPanel').style.display === 'none');
report('and storage still holds what was saved before',
written.pages[1].content === '<h1>Chapter Two</h1><p>two</p>',
written.pages[1].content);
// ---- the pruning rule on its own -------------------------------------------
//
// Pairing by filename, the key dropUntypedImages() and dropMissingImages()
// pair on. A remote url that happens to end in "/images/..." names no file
// this archive ever contained.
var images = [{filename: 'a.png', data: PNG_1X1}, {filename: 'b.png', data: PNG_1X1}];
report('only referenced images are kept',
keepReferencedImages('<img src="../images/b.png"/>', images).length === 1 &&
keepReferencedImages('<img src="../images/b.png"/>', images)[0].filename === 'b.png');
report('a remote url ending in an archive-shaped path keeps nothing',
keepReferencedImages('<img src="https://cdn.example.com/images/a.png"/>', images)
.length === 0);
report('a chapter with no images left keeps none',
keepReferencedImages('<p>text</p>', images).length === 0);
// ---- the image rewrite is reversible ---------------------------------------
var chapterForm = '<img src="../images/a.png" alt="x"/>';
var frameForm = inlineArchiveImages(chapterForm, images);
report('the preview decodes the image', frameForm.indexOf('data:image/png;base64,') > -1);
report('and restoring it gives the chapter back',
restoreArchiveImageSrc(frameForm, images) === chapterForm,
restoreArchiveImageSrc(frameForm, images));
// an old chapter may spell the reference from the wrong place; it resolves
// from pages/ either way once it has been through the frame
report('a reference that would not resolve is restored to one that does',
restoreArchiveImageSrc(inlineArchiveImages('<img src="images/a.png"/>', images), images)
.indexOf('src="../images/a.png"') > -1);
report('an image the editor never decoded is left alone',
restoreArchiveImageSrc('<img src="https://example.com/x.png"/>', images) ===
'<img src="https://example.com/x.png"/>');
exportSavedChapters(savedChapters);
done('');
}
</script>
</body>
</html>

View file

@ -13,6 +13,9 @@ var FIXTURE_PAGES = [
];
var FIXTURE_STYLES = [{title: 'Wikipedia', url: 'wikipedia\\.org', style: '#x{display:none}'}];
var sent = [];
// what was written, so a build can be checked against what the page held rather
// than against what storage would have answered with
var written = {pages: null, title: null};
window.chrome = {
runtime: {
@ -22,8 +25,11 @@ window.chrome = {
sendMessage: function(msg, cb) {
sent.push(msg.type);
var reply = {};
if (msg.type === 'set') written.pages = msg.pages;
if (msg.type === 'set title') written.title = msg.title;
if (msg.type === 'get') reply = {allPages: FIXTURE_PAGES};
if (msg.type === 'get title') reply = {title: 'My Book'};
if (msg.type === 'get uuid') reply = {uuid: 'fixture-uuid'};
if (msg.type === 'get styles') reply = {styles: FIXTURE_STYLES};
if (msg.type === 'get current style') reply = {currentStyle: 0};
if (msg.type === 'get include style') reply = {includeStyle: true};
@ -40,9 +46,13 @@ var errors = [];
window.addEventListener('error', function(e){ errors.push(e.message); });
</script>
<!-- the script set chapters.html loads, in the same order -->
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/libs/jszip.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/saveEbook.js"></script>
<script src="../../web-extension/chapterPreview.js"></script>
<script src="../../web-extension/chapterContentEditor.js"></script>
<script src="../../web-extension/chapterEditor.js"></script>
<script>
@ -68,7 +78,44 @@ setTimeout(function () {
report('page stylesheets left enabled (no global disable)',
document.styleSheets.length > 0);
document.title = 'DONE';
// ---- generating builds from the chapters the page is holding ----
//
// Not from storage: saveChanges() has only just started writing them, and
// that write is asynchronous. The editor used to reread storage here and
// race its own write.
var built = null;
buildEbook = function (pages, bookMeta) { built = {pages: pages, meta: bookMeta}; };
document.getElementById('text0').value = 'Renamed One';
document.getElementById('chapterEditor-ebookTitle').value = 'Edited Book';
// whatever storage would answer with now is stale by construction
FIXTURE_PAGES = [{title: 'Stale', url: 'stale.xhtml', content: '', images: []}];
sent = [];
document.getElementsByClassName('chapterEditor-generate-button')[0].click();
setTimeout(function () {
report('the build was handed the chapters, not left to find them', !!built);
report('both chapters reached the build', !!built && built.pages.length === 2,
built ? 'got ' + built.pages.length : 'no build');
report('the build sees the rename that was just made',
!!built && built.pages[0].title === 'Renamed One',
built ? built.pages[0].title : 'no build');
report('the build did not reread storage',
sent.indexOf('get') < 0, sent.join(','));
report('what was built is what was written',
!!built && written.pages === built.pages,
written.pages ? 'wrote ' + written.pages.length : 'nothing written');
report('the edited book title reaches the build',
!!built && built.meta.title === 'Edited Book' && written.title === 'Edited Book',
built ? built.meta.title : 'no build');
// the one thing that does have to come from storage: nothing on this page
// writes the identifier
report('the stored identifier reaches the build',
!!built && built.meta.uuid === 'fixture-uuid',
built ? String(built.meta.uuid) : 'no build');
document.title = 'DONE';
}, 100);
}, 300);
</script>
</body>

View file

@ -0,0 +1,233 @@
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="../../web-extension/chapterEditor.css">
</head>
<body>
<script>
// A 1x1 png, so the data url the preview builds is one a browser would actually
// decode rather than a placeholder string that only looks like one.
var PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
// Chapters as the extraction pipeline buffers them: content already sanitized,
// images as base64 beside it, one stylesheet per chapter.
var FIXTURE_PAGES = [
{
title: 'Chapter One',
url: 'c1.xhtml',
// in order: an image the archive has, one whose bytes never arrived, one
// that was never in the archive to begin with, and a heading that does not
// repeat the chapter title
content: '<p>one</p>' +
'<img src="../images/img-1.png" alt="kept" />' +
'<img src="../images/img-gone.png" alt="dangling" />' +
'<img src="https://example.com/remote.png" alt="remote" />' +
'<h2>A section</h2>',
images: [
{filename: 'img-1.png', data: PNG_1X1},
// no media type can be declared for this one, so the build drops it
{filename: 'img-untyped.bin', data: PNG_1X1}
],
styleFileName: 'style0.css',
styleFileContent: '.sae-one{color:rgb(1, 2, 3)}',
metadata: {lang: 'fr'}
},
{
// opens with a heading naming the chapter, so the build adds no <h1> of
// its own and neither should the preview
title: 'Chapter Two',
url: 'c2.xhtml',
content: '<h1>Chapter Two</h1><p>two</p>',
images: [],
styleFileName: 'style1.css',
styleFileContent: ''
}
];
var closedTab = false;
window.close = function(){ closedTab = true; };
window.chrome = {
runtime: {
onMessage: {addListener: function(){}},
lastError: null,
getURL: function(p){ return 'chrome-extension://fake/' + p; },
sendMessage: function(msg, cb) {
var reply = {};
if (msg.type === 'get') reply = {allPages: FIXTURE_PAGES};
if (msg.type === 'get title') reply = {title: 'My Book'};
if (msg.type === 'get uuid') reply = {uuid: 'fixture-uuid'};
if (cb) setTimeout(function(){ cb(reply); }, 0);
}
},
i18n: {getMessage: function(k){ return k; }},
tabs: {query: function(q, cb){ cb([{id: 1, url: 'https://example.com'}]); },
create: function(){}, update: function(){}},
commands: {getAll: function(cb){ cb([]); }}
};
var errors = [];
window.addEventListener('error', function(e){ errors.push(e.message); });
</script>
<!-- the script set chapters.html loads, in the same order -->
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/libs/jszip.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/saveEbook.js"></script>
<script src="../../web-extension/chapterPreview.js"></script>
<script src="../../web-extension/chapterContentEditor.js"></script>
<script src="../../web-extension/chapterEditor.js"></script>
<script>
function report(name, ok, detail) {
var li = document.createElement('li');
li.textContent = (ok ? 'PASS ' : 'FAIL ') + name + (detail ? ' -- ' + detail : '');
document.getElementById('results').appendChild(li);
}
function parsePreview(html) {
return new DOMParser().parseFromString(html, 'text/html');
}
setTimeout(function () {
var out = document.createElement('ul');
out.id = 'results';
document.body.appendChild(out);
report('scripts ran without throwing', errors.length === 0, errors.join(' | '));
// ---- the document the preview renders -------------------------------------
var html = buildChapterPreviewDocument(FIXTURE_PAGES[0], {bookCss: 'body{margin:2em}'});
var doc = parsePreview(html);
var imgs = doc.querySelectorAll('img');
report('archive images become data urls',
imgs.length > 0 && imgs[0].getAttribute('src') ===
'data:image/png;base64,' + PNG_1X1,
imgs.length ? imgs[0].getAttribute('src').substring(0, 40) : 'no images');
report('nothing is left pointing into the archive',
html.indexOf('images/img-1.png') < 0 && doc.querySelectorAll('img[src^="../"]').length === 0);
report('a reference with no bytes behind it is dropped, as in the build',
html.indexOf('img-gone.png') < 0);
// it is not the archive's to resolve, so the preview leaves it to be fetched
report('a remote image is left as it stands',
doc.querySelectorAll('img[src="https://example.com/remote.png"]').length === 1);
report('the alt text survives the rewrite',
imgs.length > 0 && imgs[0].getAttribute('alt') === 'kept');
var styles = doc.querySelectorAll('style');
report('both stylesheets are inlined, book css first',
styles.length === 2 &&
styles[0].textContent === 'body{margin:2em}' &&
styles[1].textContent === '.sae-one{color:rgb(1, 2, 3)}',
styles.length + ' style elements');
report('the chapter language reaches the preview',
doc.documentElement.getAttribute('lang') === 'fr',
String(doc.documentElement.getAttribute('lang')));
// the same body the build writes: heading added because the chapter has none
// naming itself, wrapped in the chapter section
report('the title becomes a heading when the chapter has none',
(doc.querySelector('h1') || {}).textContent === 'Chapter One');
report('the chapter section is the one the build writes',
doc.querySelectorAll('section[role="doc-chapter"]').length === 1);
report('nothing executable is in the preview document',
doc.querySelectorAll('script').length === 0 && html.indexOf('javascript:') < 0);
var second = parsePreview(buildChapterPreviewDocument(FIXTURE_PAGES[1], {bookCss: ''}));
report('a chapter that names itself is not given a second heading',
second.querySelectorAll('h1').length === 1,
second.querySelectorAll('h1').length + ' h1 elements');
// css is a file in the archive, where nothing can end it early; inlined, a
// "</style" in it would close the element and print the rest as markup
var hostile = parsePreview(buildChapterPreviewDocument(
FIXTURE_PAGES[1], {bookCss: 'p{color:red}</style><b id="escaped">x</b>'}));
report('css cannot break out of the style element it is inlined into',
hostile.querySelectorAll('#escaped').length === 0);
// ---- the panel it is rendered in ------------------------------------------
var previewButton = document.getElementById('li0').getElementsByTagName('button')[0];
var panel = document.getElementById('chapterEditor-previewPanel');
report('the preview panel starts closed', panel.style.display === 'none');
// an unsaved rename is what the chapter heading has to show
document.getElementById('text0').value = 'Renamed One';
previewButton.click();
var frame = document.getElementById('chapterEditor-previewFrame');
report('previewing renders a frame instead of alerting', !!frame);
report('the panel is open', panel.style.display === 'block');
// allow-scripts is the flag that matters and it is not there: the content is
// script-free by construction and the sandbox is what keeps it that way if it
// stops being. allow-same-origin grants the content nothing - nothing in it
// can run - and is what lets the editor reach into the document.
report('the frame is sandboxed, and not for scripts',
!!frame && frame.getAttribute('sandbox') === 'allow-same-origin',
frame ? '"' + frame.getAttribute('sandbox') + '"' : 'no frame');
report('the frame is fed by srcdoc, not a url',
!!frame && !frame.getAttribute('src') && frame.srcdoc.indexOf('<!DOCTYPE html>') === 0);
report('the preview shows the rename that has not been saved yet',
!!frame && frame.srcdoc.indexOf('<h1>Renamed One</h1>') > -1);
report('the images are decoded into this frame',
!!frame && frame.srcdoc.indexOf('data:image/png;base64,') > -1);
// one chapter at a time: the frame holding the previous chapter's decoded
// images goes before the next one is built
document.getElementById('li1').getElementsByTagName('button')[0].click();
var frames = document.getElementsByTagName('iframe');
report('only one chapter is rendered at a time', frames.length === 1,
frames.length + ' frames');
report('the second chapter replaced the first',
frames.length === 1 && frames[0].srcdoc.indexOf('two') > -1);
// escape belongs to the preview while it is open - it used to close the tab
var escapeKey = new KeyboardEvent('keydown', {keyCode: 27, which: 27});
document.onkeydown(escapeKey);
report('escape closes the preview',
document.getElementById('chapterEditor-previewPanel').style.display === 'none');
report('escape did not close the tab behind it', closedTab === false);
report('the closed panel holds no frame', document.getElementsByTagName('iframe').length === 0);
// ...and once it is closed, escape means what it meant before
document.onkeydown(escapeKey);
report('escape closes the editor once the preview is gone', closedTab === true);
// ---- and it renders ---------------------------------------------------------
//
// Everything above reads the document as a string. This one loads it, which is
// the only way to know that the data urls decode and the inlined css applies -
// the whole point of the preview. Not sandboxed, only because a sandboxed
// frame has an opaque origin and nothing here could read into it; the frame
// the editor builds is the sandboxed one, and it is checked above.
var probe = document.createElement('iframe');
probe.srcdoc = buildChapterPreviewDocument({
title: 'Rendered',
url: 'r.xhtml',
content: '<p class="sae-one">text</p><img src="../images/img-1.png" alt="kept" />',
images: [{filename: 'img-1.png', data: PNG_1X1}],
styleFileName: 'style0.css',
styleFileContent: '.sae-one{color:rgb(1, 2, 3)}'
}, {bookCss: 'p{margin:7px}'});
probe.onload = function () {
var inner = probe.contentDocument;
var image = inner.querySelector('img');
var paragraph = inner.querySelector('.sae-one');
report('the image decodes in the frame',
image.complete && image.naturalWidth === 1,
'naturalWidth=' + image.naturalWidth);
report('the chapter stylesheet applies',
inner.defaultView.getComputedStyle(paragraph).color === 'rgb(1, 2, 3)',
inner.defaultView.getComputedStyle(paragraph).color);
report('the book stylesheet applies too',
inner.defaultView.getComputedStyle(paragraph).marginTop === '7px',
inner.defaultView.getComputedStyle(paragraph).marginTop);
document.title = 'DONE';
};
document.body.appendChild(probe);
}, 300);
</script>
</body>
</html>

View file

@ -13,6 +13,7 @@ window.chrome = {
};
</script>
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/extractHtml.js"></script>
<style>
/* A shorthand that serializes: what every page got before, and must still get */

321
tests/dom/custom-css.html Normal file
View file

@ -0,0 +1,321 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../../web-extension/chapterEditor.css">
</head>
<body>
<script>
// The two stylesheets a user can write: one for the book, one for a chapter.
//
// The claim being tested is that the box and the archive agree. What is typed
// into either box is what the frame renders, and what the frame renders is what
// chapterStyleContent() and buildEbook() write into the zip - the preview is the
// only feedback there is while writing css against content, so it has to be the
// same string, assembled the same way, not a second rendering that can drift.
//
// The other claim is the one thing user css is held to: a stylesheet may not
// reach outside the archive. Everything else about it is left alone.
var FIXTURE_PAGES = [
{
title: 'Chapter One',
url: 'c1.xhtml',
content: '<p id="body-text">one</p>',
images: [],
styleFileName: 'style0.css',
styleFileContent: '#body-text{color:rgb(1, 2, 3)}',
// a chapter that was already styled, so that reopening it shows the user
// what they wrote rather than an empty box
customCss: '#body-text{padding-top:5px}'
},
{
title: 'Chapter Two',
url: 'c2.xhtml',
content: '<h1>Chapter Two</h1><p>two</p>',
images: [],
styleFileName: 'style1.css',
styleFileContent: ''
}
];
var STORED_BOOK_CSS = 'p{padding-left:3px}';
var written = {pages: null, bookCss: null};
var builds = [];
window.close = function(){};
window.confirm = function(){ return true; };
window.chrome = {
runtime: {
onMessage: {addListener: function(){}},
lastError: null,
getURL: function(p){ return 'chrome-extension://fake/' + p; },
sendMessage: function(msg, cb) {
var reply = {};
if (msg.type === 'set') written.pages = msg.pages;
if (msg.type === 'set book css') written.bookCss = msg.css;
if (msg.type === 'get') reply = {allPages: FIXTURE_PAGES};
if (msg.type === 'get title') reply = {title: 'My Book'};
if (msg.type === 'get uuid') reply = {uuid: 'fixture-uuid'};
if (msg.type === 'get book css') reply = {css: STORED_BOOK_CSS};
if (cb) setTimeout(function(){ cb(reply); }, 0);
}
},
i18n: {getMessage: function(k){ return k; }},
tabs: {query: function(q, cb){ cb([{id: 1, url: 'https://example.com'}]); },
create: function(){}, update: function(){}},
commands: {getAll: function(cb){ cb([]); }}
};
var errors = [];
window.addEventListener('error', function(e){ errors.push(e.message); });
</script>
<!-- the script set chapters.html loads, in the same order -->
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/libs/jszip.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/saveEbook.js"></script>
<script src="../../web-extension/chapterPreview.js"></script>
<script src="../../web-extension/chapterContentEditor.js"></script>
<script src="../../web-extension/chapterEditor.js"></script>
<script>
// The build is the last step and downloads a file; what this fixture needs from
// it is the book metadata the editor hands it.
var realBuildEbook = window.buildEbook;
window.buildEbook = function(pages, bookMeta) {
builds.push({pages: pages, meta: bookMeta});
};
var results = document.createElement('ul');
results.id = 'results';
document.body.appendChild(results);
function detail(text) {
var line = String(text).replace(/\s+/g, ' ').trim();
return line.length > 200 ? line.substring(0, 200) + '...' : line;
}
function report(name, ok, why) {
var li = document.createElement('li');
li.textContent = (ok ? 'PASS ' : 'FAIL ') + name + (why ? ' -- ' + detail(why) : '');
results.appendChild(li);
}
function done(message) {
if (message) {
report(message, false);
}
document.title = 'DONE';
}
function waitFor(what, predicate, next) {
var tries = 0;
(function poll() {
var ready = false;
try { ready = predicate(); } catch (e) { ready = false; }
if (ready) { next(); return; }
if (++tries > 100) { done('timed out waiting for ' + what); return; }
setTimeout(poll, 20);
})();
}
function element(id) {
return document.getElementById(id);
}
// Setting .value is not typing, and the boxes are wired to oninput
function type(box, text) {
box.value = text;
box.dispatchEvent(new Event('input', {bubbles: true}));
}
var frame = null;
var frameDoc = null;
function frameStyle(id) {
return frameDoc.getElementById(id).textContent;
}
function computed(selector, property) {
var node = frameDoc.querySelector(selector);
return frame.contentWindow.getComputedStyle(node)[property];
}
setTimeout(function () {
report('scripts ran without throwing', errors.length === 0, errors.join(' | '));
// ---- what a stylesheet may not do ------------------------------------------
//
// Everything else in it is the user's business: no allowlist, no property
// filter. These two reach outside the archive, and nothing else does.
report('an @import is removed',
sanitizeStylesheet('@import url("https://cdn.test/a.css");p{color:red}') ===
'p{color:red}',
sanitizeStylesheet('@import url("https://cdn.test/a.css");p{color:red}'));
report('a quoted @import is removed too, semicolons in the string and all',
sanitizeStylesheet('@import "a;b.css" screen;p{color:red}') === 'p{color:red}',
sanitizeStylesheet('@import "a;b.css" screen;p{color:red}'));
report('an unterminated @import at the end of the file is removed',
sanitizeStylesheet('p{color:red}@import "x.css"') === 'p{color:red}',
sanitizeStylesheet('p{color:red}@import "x.css"'));
report('a remote url becomes a value the property still accepts',
sanitizeStylesheet('p{background-image:url(https://cdn.test/x.png)}') ===
'p{background-image:none}',
sanitizeStylesheet('p{background-image:url(https://cdn.test/x.png)}'));
report('so does a scheme-relative one',
sanitizeStylesheet('p{background-image:url("//cdn.test/x.png")}') ===
'p{background-image:none}',
sanitizeStylesheet('p{background-image:url("//cdn.test/x.png")}'));
// it names a picture the book really contains
report('a reference into the archive is left alone',
sanitizeStylesheet('p{background-image:url("../images/x.png")}') ===
'p{background-image:url("../images/x.png")}');
// carried in the file rather than fetched from anywhere
report('a data url is left alone',
sanitizeStylesheet('p{background-image:url(data:image/gif;base64,R0l)}') ===
'p{background-image:url(data:image/gif;base64,R0l)}');
report('everything a reading system might not understand is still the user\'s to write',
sanitizeStylesheet('@media print{p{page-break-after:avoid;--x:1}}') ===
'@media print{p{page-break-after:avoid;--x:1}}');
report('nothing at all is not an error',
sanitizeStylesheet(undefined) === '' && sanitizeStylesheet(null) === '');
// ---- what the chapter's stylesheet is --------------------------------------
report('the captured stylesheet comes first, the user\'s after it',
chapterStyleContent({styleFileContent: 'a{color:red}', customCss: 'b{color:blue}'}) ===
'a{color:red}\nb{color:blue}',
chapterStyleContent({styleFileContent: 'a{color:red}', customCss: 'b{color:blue}'}));
report('a chapter nobody styled keeps exactly what was captured',
chapterStyleContent({styleFileContent: 'a{color:red}'}) === 'a{color:red}');
report('and one whose page gave nothing keeps exactly what was written',
chapterStyleContent({styleFileContent: '', customCss: 'b{color:blue}'}) ===
'b{color:blue}');
// ---- the document the preview renders --------------------------------------
var html = buildChapterPreviewDocument(FIXTURE_PAGES[0], {bookCss: 'body{margin:2em}'});
var doc = new DOMParser().parseFromString(html, 'text/html');
report('the book stylesheet is inlined under a name a caller can replace',
doc.getElementById('sae-preview-book-css') !== null &&
doc.getElementById('sae-preview-book-css').textContent === 'body{margin:2em}');
report('and so is the chapter\'s, holding both halves of it',
doc.getElementById('sae-preview-chapter-css').textContent ===
'#body-text{color:rgb(1, 2, 3)}\n#body-text{padding-top:5px}',
doc.getElementById('sae-preview-chapter-css').textContent);
report('the book stylesheet is first, which is the cascade the archive has',
html.indexOf('sae-preview-book-css') < html.indexOf('sae-preview-chapter-css'));
report('a stylesheet cannot reach outside the archive from the preview either',
buildChapterPreviewDocument(FIXTURE_PAGES[1],
{bookCss: '@import "https://cdn.test/a.css";p{color:red}'})
.indexOf('@import') < 0);
// ---- the box for it --------------------------------------------------------
var bookCss = element('chapterEditor-bookCss');
report('the book stylesheet box is folded away to begin with',
bookCss.style.display === 'none');
report('...and opens when it is asked for',
(element('chapterEditor-bookCssToggle').click(),
bookCss.style.display === 'block'));
report('it holds the stylesheet this book already had',
bookCss.value === STORED_BOOK_CSS, bookCss.value);
// ---- and it reaches the frame ----------------------------------------------
element('li0').getElementsByTagName('button')[0].click();
frame = element('chapterEditor-previewFrame');
var chapterCss = element('chapterEditor-chapterCss');
report('opening a chapter that has its own css opens the box holding it',
element('chapterEditor-chapterCssHolder').style.display === 'block' &&
chapterCss.value === '#body-text{padding-top:5px}',
chapterCss.value);
waitFor('the frame to load', function() {
frameDoc = frame.contentDocument;
return frameDoc && frameDoc.getElementById('body-text');
}, function() {
report('the stylesheet the book already had is rendering',
computed('#body-text', 'paddingLeft') === '3px',
computed('#body-text', 'paddingLeft'));
report('and so is the chapter\'s own',
computed('#body-text', 'color') === 'rgb(1, 2, 3)' &&
computed('#body-text', 'paddingTop') === '5px',
computed('#body-text', 'color') + ' / ' + computed('#body-text', 'paddingTop'));
// Typing, not saving: a rule that only takes effect once it has been stored
// cannot be written against content you are looking at.
type(bookCss, 'p{padding-left:11px}');
report('a rule typed into the book box takes effect as it is typed',
computed('#body-text', 'paddingLeft') === '11px',
computed('#body-text', 'paddingLeft'));
report('and it replaced that stylesheet rather than being added beside it',
frameDoc.querySelectorAll('style').length === 3,
frameDoc.querySelectorAll('style').length + ' style elements');
type(chapterCss, '#body-text{padding-bottom:13px}');
report('a rule typed into the chapter box takes effect too',
computed('#body-text', 'paddingBottom') === '13px',
computed('#body-text', 'paddingBottom'));
report('...without losing the stylesheet captured from the page',
computed('#body-text', 'color') === 'rgb(1, 2, 3)' &&
frameStyle('sae-preview-chapter-css')
.indexOf('#body-text{color:rgb(1, 2, 3)}') === 0,
frameStyle('sae-preview-chapter-css'));
// the frame is what the user is judging the css by, so it may not show a
// picture the book will not have
type(bookCss, 'p{background-image:url(https://cdn.test/x.png)}');
report('a remote reference is gone from the preview, as it will be from the book',
frameStyle('sae-preview-book-css') === 'p{background-image:none}',
frameStyle('sae-preview-book-css'));
type(bookCss, 'p{padding-left:11px}');
// ---- into storage ---------------------------------------------------------
report('the chapter css alone is enough to have something to save',
!element('chapterEditor-saveChapter').disabled);
element('chapterEditor-saveChapter').click();
waitFor('the chapter to be saved', function() { return written.pages !== null; },
function() {
report('this chapter\'s css was stored on its record',
written.pages[0].customCss === '#body-text{padding-bottom:13px}',
String(written.pages[0].customCss));
report('the chapter beside it was not given one',
!written.pages[1].customCss, String(written.pages[1].customCss));
report('the captured stylesheet was not overwritten with the merged one',
written.pages[0].styleFileContent === '#body-text{color:rgb(1, 2, 3)}',
written.pages[0].styleFileContent);
report('the book stylesheet was stored beside the title, not on a chapter',
written.bookCss === 'p{padding-left:11px}', String(written.bookCss));
report('and saving it left nothing more to save',
element('chapterEditor-saveChapter').disabled);
// ---- a chapter that has none ---------------------------------------------
element('li1').getElementsByTagName('button')[0].click();
report('opening a chapter with no css of its own opens an empty box, folded away',
element('chapterEditor-chapterCssHolder').style.display === 'none' &&
element('chapterEditor-chapterCss').value === '',
element('chapterEditor-chapterCss').value);
// ---- and what the build is handed -----------------------------------------
document.getElementsByClassName('chapterEditor-generate-button')[0].click();
waitFor('the build to be started', function() { return builds.length > 0; },
function() {
report('the build is handed the book stylesheet, from the box and not from storage',
builds[0].meta.css === 'p{padding-left:11px}', String(builds[0].meta.css));
report('along with the title and the identifier it already took',
builds[0].meta.title === 'My Book' && builds[0].meta.uuid === 'fixture-uuid',
JSON.stringify(builds[0].meta));
report('and the chapters carry their own css into it',
builds[0].pages[0].customCss === '#body-text{padding-bottom:13px}',
String(builds[0].pages[0].customCss));
done('');
});
});
});
}, 300);
</script>
</body>
</html>

View file

@ -8,6 +8,7 @@ window.chrome = {
};
</script>
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/extractHtml.js"></script>
<base href="https://example.com/blog/">
</head>

View file

@ -8,6 +8,7 @@ window.chrome = {
};
</script>
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/extractHtml.js"></script>
<base href="https://example.com/paper/">
<style>

View file

@ -14,6 +14,7 @@ window.chrome = {
};
</script>
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/extractHtml.js"></script>
<script src="../../web-extension/saveEbook.js"></script>
</head>

View file

@ -12,6 +12,7 @@ window.chrome = {
};
</script>
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/extractHtml.js"></script>
<base href="https://example.com/blog/">
<style>

View file

@ -10,6 +10,7 @@ window.chrome = {
</script>
<script src="../../web-extension/libs/readability.js"></script>
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/extractHtml.js"></script>
</head>
<body>

View file

@ -10,6 +10,7 @@ window.chrome = {
</script>
<script src="../../web-extension/libs/readability.js"></script>
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/extractHtml.js"></script>
<style>
.gone { display: none; }

View file

@ -0,0 +1,163 @@
<!doctype html>
<html>
<head>
<!-- The point of the shared sanitizer is that it runs without a page to
extract: sanitizeHtml.js is loaded here on its own for most of these
assertions, exactly as the chapter editor loads it. extractHtml.js comes
after it only so the last group can check that what extraction writes is
already a fixed point of the editor's sanitizer - the round trip an edit
depends on. -->
<script>
window.chrome = {
runtime: {onMessage: {addListener: function(){}}, sendMessage: function(){}},
i18n: {getMessage: function(){ return ''; }}
};
</script>
<script src="../../web-extension/utils.js"></script>
<script src="../../web-extension/sanitizeHtml.js"></script>
<script src="../../web-extension/extractHtml.js"></script>
<base href="https://example.com/blog/">
</head>
<body>
<article id="source">
<p class="lead">Text with &amp; an entity &nbsp; and a <b>bold</b> run.</p>
<blockquote cite="/sources/one"><p>Quoted</p></blockquote>
<p><a href="#note-1">a link into the page</a> and
<a href="/elsewhere">one off it</a>.</p>
<table><tr><td colspan="2">cell</td></tr></table>
<p id="note-1" lang="fr">Une note</p>
<img src="/img/logo.png" alt="a logo">
</article>
<script>
// The runner reads results a line at a time, so a detail carrying a newline -
// which every one of these content strings does - would hide the result it
// belongs to.
function detail(text) {
var line = String(text).replace(/\s+/g, ' ').trim();
return line.length > 300 ? line.substring(0, 300) + '...' : line;
}
function report(name, ok, why) {
var li = document.createElement('li');
li.textContent = (ok ? 'PASS ' : 'FAIL ') + name + (why ? ' -- ' + detail(why) : '');
document.getElementById('results').appendChild(li);
}
// The contract, checked rather than assumed: a chapter is written into an XHTML
// document, and a reading system parses it as XML.
function xmlError(content) {
var doc = new DOMParser().parseFromString(
'<html xmlns="http://www.w3.org/1999/xhtml"><body>' + content + '</body></html>',
'application/xhtml+xml');
var error = doc.querySelector('parsererror');
return error ? error.textContent.replace(/\s+/g, ' ').substring(0, 120) : '';
}
function reportWellFormed(name, content) {
var error = xmlError(content);
report(name, error === '', error);
}
window.addEventListener('load', function () {
var out = document.createElement('ul');
out.id = 'results';
document.body.appendChild(out);
// ---- 1. what a user edit can put in, and what may come out ----
var edited =
'<p onclick="steal()" style="color:red" class="cls0">kept text</p>' +
'<script>bad()<\/script>' +
'<p>tail <b>unclosed' +
'</div>' +
'<img src="https://cdn.example.net/remote.png" alt="remote">' +
'<img src="../images/img-1.png" alt="local">' +
'<a href="javascript:steal()">js</a>' +
'<a href="relative/page.html">relative</a>' +
'<a href="https://example.org/x">absolute</a>' +
'<a href="#nope!">bad fragment</a>' +
'<custom-widget>unwrapped, text stays</custom-widget>';
var clean = sanitizeChapterContent(edited);
reportWellFormed('edited content is well formed XML', clean);
report('scripts and their content are dropped',
clean.indexOf('bad()') < 0 && clean.indexOf('<script') < 0, clean);
report('event handlers are dropped', clean.indexOf('onclick') < 0);
report('inline style is dropped', clean.indexOf('style=') < 0);
report('the generated class survives an edit', clean.indexOf('class="cls0"') > -1, clean);
report('an unknown element is unwrapped, its text kept',
clean.indexOf('unwrapped, text stays') > -1 && clean.indexOf('custom-widget') < 0);
// The parser adopts everything after it into the <b>, and a stray </div>
// closes nothing - what has to hold is that the tag is closed exactly once,
// which is what the XML check above rests on.
report('an unclosed tag is closed',
(clean.match(/<b>/g) || []).length === 1 &&
(clean.match(/<\/b>/g) || []).length === 1, clean);
report('an image the archive does not hold is dropped',
clean.indexOf('remote.png') < 0, clean);
report('an image the archive holds is kept',
clean.indexOf('src="../images/img-1.png"') > -1, clean);
report('a javascript: link loses its href, keeps its text',
clean.indexOf('javascript:') < 0 && clean.indexOf('>js<') > -1, clean);
// Nothing on the editor page says what a relative reference is relative to -
// the editor's own address certainly is not it.
report('an unresolvable relative link loses its href',
clean.indexOf('relative/page.html') < 0 && clean.indexOf('>relative<') > -1, clean);
report('an absolute link is kept',
clean.indexOf('href="https://example.org/x"') > -1, clean);
report('a fragment that is not an XML name loses its href',
clean.indexOf('#nope!') < 0, clean);
// ---- 2. ids stay unique within the chapter ----
var duplicated = sanitizeChapterContent('<p id="a">one</p><p id="a">two</p><p id="9bad">three</p>');
report('the first of two equal ids keeps it',
(duplicated.match(/ id="a"/g) || []).length === 1, duplicated);
report('an id that is not an XML name is dropped',
duplicated.indexOf('9bad') < 0, duplicated);
// ---- 3. entities and characters XML cannot carry ----
var entities = sanitizeChapterContent('<p>a &amp; b &nbsp; c &mdash; d</p>');
reportWellFormed('decoded entities are re-escaped as XML predefines', entities);
report('no named entity survives',
entities.indexOf('&nbsp;') < 0 && entities.indexOf('&mdash;') < 0 &&
entities.indexOf('&amp;amp;') < 0 && entities.indexOf('a &amp; b') > -1,
entities);
// ---- 4. extraction output is a fixed point of the editor's sanitizer ----
//
// This is what makes an edit safe: the editor hands back a string built from
// content extraction wrote, and running it through again may tidy it but must
// not lose anything.
allImages = []; extractedImages = []; imageFileNames.clear();
classNameToCss.clear(); cssToClassName.clear(); usedElementIds.clear();
var state = readLivePage(true, []);
var clone = document.getElementById('source').cloneNode(true);
clearLiveMarks(state);
var extracted = getContent(clone, state);
reportWellFormed('extracted content is well formed XML', extracted);
var again = sanitizeChapterContent(extracted);
report('re-sanitizing extraction output changes nothing', again === extracted,
'before=' + extracted + ' after=' + again);
report('...and it was not empty to begin with',
extracted.indexOf('kept') > -1 || extracted.indexOf('Text with') > -1, extracted);
report('the generated css classes survived the round trip',
(again.match(/ class="/g) || []).length ===
(extracted.match(/ class="/g) || []).length, again);
report('a link into the chapter survived the round trip',
again.indexOf('href="#note-1"') > -1, again);
report('an absolutised link survived the round trip',
again.indexOf('href="https://example.com/elsewhere"') > -1, again);
report('the archive image reference survived the round trip',
(again.match(/\.\.\/images\//g) || []).length === 1, again);
report('blockquote cite survived the round trip',
again.indexOf('cite="https://example.com/sources/one"') > -1, again);
document.title = 'DONE';
});
</script>
</body>
</html>

View file

@ -12,7 +12,8 @@ const SVG_BYTES = Buffer.from(
).toString('base64');
function makeSandbox() {
const state = {title: 'Scenario Book', uuid: null, pages: [], downloads: [], messages: [], alerts: []};
const state = {title: 'Scenario Book', uuid: null, css: '', pages: [], downloads: [],
messages: [], alerts: []};
const sandbox = {
console,
setTimeout,
@ -47,6 +48,7 @@ function makeSandbox() {
let response = {};
if (message.type === 'get title') response = {title: state.title};
if (message.type === 'get uuid') response = {uuid: state.uuid};
if (message.type === 'get book css') response = {css: state.css};
if (message.type === 'get') response = {allPages: state.pages};
if (callback) callback(response);
}
@ -91,6 +93,7 @@ async function build(env, pages, options) {
const {sandbox, state} = env;
state.title = options.title === undefined ? 'Scenario Book' : options.title;
state.uuid = options.uuid || null;
state.css = options.css || '';
state.pages = pages;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('builder did not produce an EPUB')), 5000);
@ -102,13 +105,13 @@ async function build(env, pages, options) {
resolve(result);
}, reject);
};
if (options.path === 'chapters') sandbox.buildEbookFromChapters();
// 'chapters' is the build that reads the book back out of storage;
// 'one-shot' is a saved page, which passes no book metadata at all. The
// default is the editor's call: the chapters in hand, plus what the page
// knows about the book.
if (options.path === 'chapters') sandbox.buildEbookFromStorage();
else if (options.path === 'one-shot') sandbox.buildEbook(pages);
else {
sandbox.ebookTitle = state.title;
sandbox.ebookUuid = state.uuid;
sandbox._buildEbook(pages);
}
else sandbox.buildEbook(pages, {title: state.title, uuid: state.uuid, css: state.css});
});
}
@ -176,7 +179,7 @@ function equal(actual, expected, message) {
await scenario('empty input is rejected and malformed records beside a valid chapter are skipped', async () => {
const empty = makeSandbox();
for (const value of [undefined, null, {}, [], [null, undefined, 'broken', {}]]) {
empty.sandbox._buildEbook(value);
empty.sandbox.buildEbook(value, {title: 'Scenario Book'});
}
await new Promise((resolve) => setTimeout(resolve, 0));
equal(empty.state.downloads.length, 0, 'empty input must not download a book');
@ -421,6 +424,80 @@ function equal(actual, expected, message) {
'a chapter with no metadata at all should build as left to right');
});
await scenario('the book stylesheet is written, linked, and cascades under the chapter\'s own', async () => {
const env = makeSandbox();
const epub = await inspect(env, await build(env, [chapter({
styleFileContent: '.captured{color:#111}',
customCss: 'p{text-align:justify}'
})], {css: 'body{font-family:serif}'}));
equal(await epub.read('OEBPS/' + 'ebook.css'), 'body{font-family:serif}',
'the book css should be written into ebook.css');
const page = await epub.read('OEBPS/pages/chapter.xhtml');
ok(page.includes('<link href="../ebook.css" rel="stylesheet" type="text/css" />'),
'every chapter should link the book stylesheet');
// order is the cascade: the chapter's own is the more specific answer
ok(page.indexOf('../ebook.css') < page.indexOf('../style/chapter.css'),
'the book stylesheet should be linked before the chapter\'s own');
ok(epub.nav.includes('<link href="ebook.css"'),
'the table of contents should link it too, from the root it sits in');
equal(await epub.read('OEBPS/style/chapter.css'),
'.captured{color:#111}\np{text-align:justify}',
'a chapter\'s own css should be appended to what extraction captured');
});
await scenario('a book nobody styled is byte for byte what it was before there was a box to style it in', async () => {
const env = makeSandbox();
const epub = await inspect(env, await build(env, [chapter()]));
equal(await epub.read('OEBPS/ebook.css'), '',
'no book css means an empty ebook.css, as it always was');
equal(await epub.read('OEBPS/style/chapter.css'), '',
'no chapter css means the captured stylesheet unchanged');
});
await scenario('a stylesheet cannot reach outside the archive', async () => {
const env = makeSandbox();
const epub = await inspect(env, await build(env, [chapter({
// captured css reaches this too: 'list-style' serializes an absolute
// url whenever the source page gave its bullets a picture
styleFileContent: 'ul{list-style:outside none url("https://cdn.test/bullet.png")}',
customCss: [
'@import url("https://fonts.test/face.css");',
"@import 'local.css';",
'.remote{background-image:url(https://cdn.test/bg.png)}',
'.schemeless{background-image:url(//cdn.test/bg.png)}',
'.local{background-image:url("../images/local.png")}',
'.inline{background-image:url(data:image/gif;base64,R0lGOD)}',
'.kept{color:red}'
].join('\n')
})], {css: '@import "https://cdn.test/book.css";\nbody{background-image:url(http://cdn.test/paper.png)}'}));
const book = await epub.read('OEBPS/ebook.css');
ok(!book.includes('@import'), 'the book css should carry no @import');
ok(!book.includes('cdn.test'), 'the book css should fetch nothing off the network');
ok(book.includes('background-image:none'),
'a removed url should leave a value the property accepts: ' + book);
const style = await epub.read('OEBPS/style/chapter.css');
ok(!style.includes('@import'), 'no @import survives, quoted or in a url()');
ok(!style.includes('cdn.test') && !style.includes('fonts.test'),
'nothing remote survives: ' + style);
ok(!style.includes('local.css'),
'a local @import names a file the archive cannot contain either');
// EPUB requires an item referencing a remote resource to say so, and
// this build makes no such claim about any file it writes
ok(!epub.opf.includes('remote-resources'),
'and so no manifest item has to claim remote resources');
ok(style.includes('url("../images/local.png")'),
'a reference to a picture the book really contains is kept: ' + style);
ok(style.includes('url(data:image/gif;base64,R0lGOD)'),
'a data url is carried in the file rather than fetched, and is kept');
ok(style.includes('.kept{color:red}'), 'and everything else is left alone');
});
console.log(failures === 0 ? '\nepub builder scenarios OK' : '\n' + failures + ' scenario failure(s)');
process.exit(failures === 0 ? 0 : 1);
})().catch((error) => {

View file

@ -41,6 +41,14 @@ for build in "fixture.epub" "single.epub --single"; do
./run-epubcheck.sh "out/$1" || status=1
done
# The build stage relies on invariants the extraction pipeline guarantees. Once
# a user has edited the content in a live DOM, the only thing standing between
# that DOM and an unparseable chapter is the shared sanitizer - and EPUBCheck is
# the arbiter of whether it held.
echo
echo "### building and validating a book from edited chapters"
./build-edited-epub.sh || status=1
echo
echo "### negative EPUB structure checks"
node check-epub-negative.js "out/fixture.epub" || status=1

View file

@ -13,7 +13,8 @@
"message": "Keep only the article, dropping navigation, sidebars and comments. Applies to Save Page and Add Page as Chapter only. Pages that are not articles are saved whole."
},
"editStyles": {
"message": "Edit Custom Styles ..."
"message": "Edit Site Styles ...",
"description": "Per-site CSS applied to a page while it is captured - not the stylesheet of the book that comes out, which lives in the chapter editor"
},
"savePage": {
"message": "Save Page"
@ -39,8 +40,80 @@
"ebookTitleLabel": {
"message": "eBook Title:"
},
"rawPreview": {
"message": "Raw Preview"
"preview": {
"message": "Preview"
},
"previewNote": {
"message": "This shows how the chapter renders. Pagination, fonts and margins are the reading system's and differ between devices."
},
"previewEmpty": {
"message": "This chapter has no content to preview."
},
"editModeView": {
"message": "View"
},
"editModeRemove": {
"message": "Remove elements"
},
"editModeText": {
"message": "Edit text"
},
"viewModeHint": {
"message": "Reading view. Switch mode to edit this chapter."
},
"removeModeHint": {
"message": "Click an element to remove it. Select parent widens what a click takes."
},
"removeModeTarget": {
"message": "Click removes:"
},
"textModeHint": {
"message": "Click in the text to edit it. The chapter title is edited in the chapter list."
},
"selectParent": {
"message": "Select parent"
},
"selectChild": {
"message": "Select child"
},
"insertParagraphBefore": {
"message": "Add paragraph before"
},
"insertParagraphAfter": {
"message": "Add paragraph after"
},
"insertedParagraphText": {
"message": "New paragraph"
},
"undoEdit": {
"message": "Undo"
},
"saveChapter": {
"message": "Save chapter"
},
"chapterSaved": {
"message": "Chapter saved."
},
"bookCssLabel": {
"message": "eBook Stylesheet"
},
"bookCssHint": {
"message": "CSS for the whole saved book. Not the same as Site Styles, which change pages while they are captured."
},
"bookCssPlaceholder": {
"message": "body { font-family: serif; }"
},
"chapterCssLabel": {
"message": "Chapter CSS"
},
"chapterCssHint": {
"message": "CSS for this chapter only, applied after the styles captured from its page."
},
"chapterCssPlaceholder": {
"message": "p { text-align: justify; }"
},
"discardEditsConfirm": {
"message": "This chapter has unsaved edits. Close without saving?"
},
"remove": {
"message": "Remove"
@ -73,7 +146,7 @@
"message": "Style saved!"
},
"styleEditor": {
"message": "Style Editor"
"message": "Site Style Editor"
},
"selectExistingCSS": {
"message": "Select Existing CSS"

View file

@ -19,7 +19,7 @@
"message": "Appliquer"
},
"editStyles": {
"message": "Éditer les styles..."
"message": "Éditer les styles de site..."
},
"savePage": {
"message": "Enregistrer la page"
@ -45,8 +45,80 @@
"ebookTitleLabel": {
"message": "Titre de l'eBook :"
},
"rawPreview": {
"message": "Aperçu brut"
"preview": {
"message": "Aperçu"
},
"previewNote": {
"message": "Ceci montre le rendu du chapitre. La pagination, les polices et les marges appartiennent à la liseuse et varient selon les appareils."
},
"previewEmpty": {
"message": "Ce chapitre n'a aucun contenu à prévisualiser."
},
"editModeView": {
"message": "Lecture"
},
"editModeRemove": {
"message": "Supprimer des éléments"
},
"editModeText": {
"message": "Modifier le texte"
},
"viewModeHint": {
"message": "Mode lecture. Changez de mode pour modifier ce chapitre."
},
"removeModeHint": {
"message": "Cliquez sur un élément pour le supprimer. « Sélectionner le parent » élargit ce que le clic supprime."
},
"removeModeTarget": {
"message": "Le clic supprime :"
},
"textModeHint": {
"message": "Cliquez dans le texte pour le modifier. Le titre du chapitre se modifie dans la liste des chapitres."
},
"selectParent": {
"message": "Sélectionner le parent"
},
"selectChild": {
"message": "Sélectionner l'enfant"
},
"insertParagraphBefore": {
"message": "Ajouter un paragraphe avant"
},
"insertParagraphAfter": {
"message": "Ajouter un paragraphe après"
},
"insertedParagraphText": {
"message": "Nouveau paragraphe"
},
"undoEdit": {
"message": "Annuler"
},
"saveChapter": {
"message": "Enregistrer le chapitre"
},
"chapterSaved": {
"message": "Chapitre enregistré."
},
"bookCssLabel": {
"message": "Feuille de style du livre"
},
"bookCssHint": {
"message": "CSS appliqué à tout le livre enregistré. À ne pas confondre avec les styles de site, qui modifient les pages lors de leur capture."
},
"bookCssPlaceholder": {
"message": "body { font-family: serif; }"
},
"chapterCssLabel": {
"message": "CSS du chapitre"
},
"chapterCssHint": {
"message": "CSS appliqué à ce seul chapitre, après les styles capturés depuis sa page."
},
"chapterCssPlaceholder": {
"message": "p { text-align: justify; }"
},
"discardEditsConfirm": {
"message": "Ce chapitre a des modifications non enregistrées. Fermer sans enregistrer ?"
},
"remove": {
"message": "Retirer"
@ -79,7 +151,7 @@
"message": "Style enregistré !"
},
"styleEditor": {
"message": "Éditeur de style"
"message": "Éditeur de styles de site"
},
"selectExistingCSS": {
"message": "Sélectionner un style existant"

View file

@ -13,7 +13,7 @@
"message": "Manter apenas o artigo, removendo navegação, barras laterais e comentários. Aplica-se somente a «Salvar página» e «Adicionar página como capítulo». Páginas que não são artigos são salvas por inteiro."
},
"editStyles": {
"message": "Editar estilos personalizados ..."
"message": "Editar estilos do site ..."
},
"savePage": {
"message": "Salvar Página"
@ -39,9 +39,81 @@
"ebookTitleLabel": {
"message": "Título do eBook:"
},
"rawPreview": {
"preview": {
"message": "Pré-visualização"
},
"previewNote": {
"message": "Isto mostra como o capítulo é renderizado. Paginação, fontes e margens são do leitor de eBooks e variam conforme o dispositivo."
},
"previewEmpty": {
"message": "Este capítulo não tem conteúdo para pré-visualizar."
},
"editModeView": {
"message": "Leitura"
},
"editModeRemove": {
"message": "Remover elementos"
},
"editModeText": {
"message": "Editar texto"
},
"viewModeHint": {
"message": "Modo de leitura. Mude de modo para editar este capítulo."
},
"removeModeHint": {
"message": "Clique em um elemento para removê-lo. \"Selecionar o pai\" amplia o que o clique remove."
},
"removeModeTarget": {
"message": "O clique remove:"
},
"textModeHint": {
"message": "Clique no texto para editá-lo. O título do capítulo é editado na lista de capítulos."
},
"selectParent": {
"message": "Selecionar o pai"
},
"selectChild": {
"message": "Selecionar o filho"
},
"insertParagraphBefore": {
"message": "Adicionar parágrafo antes"
},
"insertParagraphAfter": {
"message": "Adicionar parágrafo depois"
},
"insertedParagraphText": {
"message": "Novo parágrafo"
},
"undoEdit": {
"message": "Desfazer"
},
"saveChapter": {
"message": "Salvar capítulo"
},
"chapterSaved": {
"message": "Capítulo salvo."
},
"bookCssLabel": {
"message": "Folha de estilo do livro"
},
"bookCssHint": {
"message": "CSS aplicado a todo o livro salvo. Não confundir com os estilos do site, que alteram as páginas durante a captura."
},
"bookCssPlaceholder": {
"message": "body { font-family: serif; }"
},
"chapterCssLabel": {
"message": "CSS do capítulo"
},
"chapterCssHint": {
"message": "CSS aplicado apenas a este capítulo, após os estilos capturados da sua página."
},
"chapterCssPlaceholder": {
"message": "p { text-align: justify; }"
},
"discardEditsConfirm": {
"message": "Este capítulo tem alterações não salvas. Fechar sem salvar?"
},
"remove": {
"message": "Remover"
},
@ -73,7 +145,7 @@
"message": "Estilo salvo!"
},
"styleEditor": {
"message": "Editor de estilo"
"message": "Editor de estilos do site"
},
"selectExistingCSS": {
"message": "Selecionar CSS Existente"

View file

@ -13,7 +13,7 @@
"message": "Оставить только статью, убрав навигацию, боковые панели и комментарии. Работает только для «Сохранить страницу» и «Добавить страницу как главу». Страницы, не являющиеся статьями, сохраняются целиком."
},
"editStyles": {
"message": "Редактировать CSS ..."
"message": "Редактировать стили сайтов ..."
},
"savePage": {
"message": "Сохранить страницу"
@ -39,9 +39,81 @@
"ebookTitleLabel": {
"message": "Заголовок книги:"
},
"rawPreview": {
"preview": {
"message": "Предпросмотр"
},
"previewNote": {
"message": "Здесь видно, как выглядит глава. Разбивка на страницы, шрифты и поля задаются читалкой и различаются на разных устройствах."
},
"previewEmpty": {
"message": "В этой главе нечего показывать."
},
"editModeView": {
"message": "Чтение"
},
"editModeRemove": {
"message": "Удаление элементов"
},
"editModeText": {
"message": "Редактирование текста"
},
"viewModeHint": {
"message": "Режим чтения. Смените режим, чтобы редактировать главу."
},
"removeModeHint": {
"message": "Нажмите на элемент, чтобы удалить его. «Выбрать родителя» расширяет удаляемую область."
},
"removeModeTarget": {
"message": "Клик удалит:"
},
"textModeHint": {
"message": "Нажмите на текст, чтобы изменить его. Заголовок главы редактируется в списке глав."
},
"selectParent": {
"message": "Выбрать родителя"
},
"selectChild": {
"message": "Выбрать потомка"
},
"insertParagraphBefore": {
"message": "Добавить абзац перед"
},
"insertParagraphAfter": {
"message": "Добавить абзац после"
},
"insertedParagraphText": {
"message": "Новый абзац"
},
"undoEdit": {
"message": "Отменить"
},
"saveChapter": {
"message": "Сохранить главу"
},
"chapterSaved": {
"message": "Глава сохранена."
},
"bookCssLabel": {
"message": "Таблица стилей книги"
},
"bookCssHint": {
"message": "CSS для всей сохраняемой книги. Не путать со стилями сайтов, которые изменяют страницы при захвате."
},
"bookCssPlaceholder": {
"message": "body { font-family: serif; }"
},
"chapterCssLabel": {
"message": "CSS главы"
},
"chapterCssHint": {
"message": "CSS только для этой главы, применяется после стилей, захваченных с её страницы."
},
"chapterCssPlaceholder": {
"message": "p { text-align: justify; }"
},
"discardEditsConfirm": {
"message": "В этой главе есть несохранённые изменения. Закрыть без сохранения?"
},
"remove": {
"message": "Удалить"
},
@ -73,7 +145,7 @@
"message": "Стиль сохранен!"
},
"styleEditor": {
"message": "Редактор стилей"
"message": "Редактор стилей сайтов"
},
"selectExistingCSS": {
"message": "Выбрать существующий стиль"

View file

@ -59,7 +59,7 @@ let jobClaimPending = false
// acted on, and the alternative - injecting it separately when the setting is on
// - leaves a tab that was injected with the setting off unable to honour it when
// the user turns it on without reloading.
const CONTENT_SCRIPTS = ['libs/jszip.js', 'libs/readability.js', 'utils.js', 'extractHtml.js', 'saveEbook.js']
const CONTENT_SCRIPTS = ['libs/jszip.js', 'libs/readability.js', 'utils.js', 'sanitizeHtml.js', 'extractHtml.js', 'saveEbook.js']
var defaultStyles = [
{
@ -600,6 +600,9 @@ function _execRequest(request, sender, sendResponse) {
// the identifier belongs to the discarded set of chapters - the next
// ebook is a different book and must not reuse it
chrome.storage.local.remove('uuid');
// and so does the stylesheet written for them. The per-site styles under
// 'styles' are not touched: those belong to the sites, not to this book.
chrome.storage.local.remove('bookCss');
}
// Minted on first use and kept until the ebook is discarded, so rebuilding
// after editing chapters produces the same dc:identifier. The service worker
@ -628,6 +631,16 @@ function _execRequest(request, sender, sendResponse) {
if (request.type === 'set title') {
chrome.storage.local.set({'title': request.title});
}
// The book-wide stylesheet. Empty until somebody writes one, which is what
// every book built before the editor had a box for it had.
if (request.type === 'get book css') {
chrome.storage.local.get('bookCss', function (data) {
sendResponse({css: data && typeof data.bookCss === 'string' ? data.bookCss : ''});
})
}
if (request.type === 'set book css') {
chrome.storage.local.set({'bookCss': typeof request.css === 'string' ? request.css : ''});
}
if (request.type === 'get styles') {
chrome.storage.local.get('styles', function (data) {
if (!data || !data.styles) {

View file

@ -0,0 +1,623 @@
// Editing a chapter inside the frame that previews it.
//
// The preview frame already holds the chapter as a live DOM, rendered by the
// same functions that write the book. Editing it is therefore editing that DOM
// directly - there is no second copy of the content to keep in step - and the
// only question is how what comes back out becomes a chapter again.
//
// The answer, and the rule the whole file exists to keep: nothing this produces
// reaches storage without going through sanitizeChapterContent() first. An
// innerHTML string off a contentEditable DOM satisfies none of the invariants
// the build stage relies on - unclosed void tags, unquoted attributes, whatever
// the browser's editing commands felt like inserting - and the sanitizer is the
// single place that turns any string into one that does.
//
// Two things make that round trip work:
//
// The content being edited is wrapped in a container of this module's own.
// Everything inside it is the chapter; everything outside it - the <section>
// the build writes, and the <h1> it adds when the page has no heading naming
// itself - is the build's, not the user's. Serializing is then reading one
// element's innerHTML rather than reasoning about which children were the
// page's own, and the generated heading cannot be edited into something the
// build would only add again underneath it.
//
// The image rewrites the preview makes are undone before anything is read
// back. The frame holds data urls; a chapter holds references into the
// archive.
//
// What saving an edit does bake into the stored chapter, because the frame was
// rendered by the build stage and not only by the preview: the ids minted for
// headings that had none, and the internal links resolved against them. Both are
// stable - a minted id is unique within its chapter and buildChapterOutline()
// keeps an id it finds rather than minting over it - so a chapter that has been
// edited builds to the same thing as one that has not.
//
// Requires utils.js, sanitizeHtml.js, saveEbook.js and chapterPreview.js.
// A class on a content element, so it has to be off again before the content is
// read back - class is an attribute the sanitizer keeps, being the one the
// generated chapter stylesheet is written against.
var EDIT_HOVER_CLASS = 'sae-edit-hover';
// On the frame's <body>, which is never serialized
var EDIT_MODE_CLASS_PREFIX = 'sae-edit-mode-';
// In the frame's <head>, which is never serialized either
var EDIT_STYLE_ID = 'sae-edit-style';
var EDIT_MODES = ['view', 'remove', 'text'];
// A snapshot is the chapter as it would be stored - references into the archive,
// not the base64 the frame renders - so a deep stack costs the length of the
// markup rather than the size of the pictures. Storage is not touched until the
// user saves, so this stack is the only record of the steps in between.
var MAX_UNDO_STEPS = 30;
// A run of typing is one undo step. The snapshot is taken when the run starts;
// this is how long a pause has to be before the next keystroke starts a new one.
var TYPING_BURST_MS = 1200;
// A ceiling on widening, for the case where there is nothing under the pointer
// to tell how far up the content already goes
var MAX_WIDEN_LEVELS = 20;
// Injected into the frame, not into the chapter: it styles what the editor is
// doing to the chapter and leaves the chapter's own rendering alone. !important
// throughout because the chapter stylesheet is generated from a real page's
// computed styles and will have opinions about outlines and cursors.
var EDIT_STYLESHEET =
'.' + EDIT_HOVER_CLASS + '{' +
'outline:2px solid #d33 !important;' +
'outline-offset:-2px !important;' +
'background-color:rgba(221,51,51,0.12) !important;' +
'}' +
'body.' + EDIT_MODE_CLASS_PREFIX + 'remove *{cursor:crosshair !important;}' +
'body.' + EDIT_MODE_CLASS_PREFIX + 'text [data-sae-edit-root]{' +
'outline:1px dashed #7aa !important;' +
'outline-offset:4px !important;' +
'}';
// The <img src> forms sanitizeChapterContent() lets through, which is what the
// content has been through by the time this reads it. Anchored at the start for
// the same reason dropMissingImages() anchors: a remote url ending in
// "/images/photo.jpg" names a file this archive never contained.
var EDITED_IMAGE_SRC_REGEX = /<img\b[^>]*\ssrc="((?:\.{1,2}\/)*images\/[^"?#]+)"/gi;
// Which of the chapter's images the edited content still refers to. An <img>
// removed with its surrounding element leaves bytes behind that nothing points
// at, and a manifest item for a picture no chapter shows is a file the reader
// downloads and never draws - epubcheck reports it, and it is dead weight in
// every copy of the book.
//
// The pairing is by filename, the same key dropUntypedImages() and
// dropMissingImages() pair on, so a filename referenced anywhere in the chapter
// keeps its record and nothing else does.
function keepReferencedImages(content, images) {
let referenced = Object.create(null);
let match;
EDITED_IMAGE_SRC_REGEX.lastIndex = 0;
while ((match = EDITED_IMAGE_SRC_REGEX.exec(content)) !== null) {
referenced[match[1].replace(/^(?:\.{1,2}\/)*images\//i, '')] = true;
}
return (images || []).filter(function(image) {
return referenced[image.filename] === true;
});
}
// Starts an editing session on a rendered preview frame.
//
// frameDocument the loaded document inside the preview iframe
// chapter the normalized chapter it was rendered from, as
// buildChapterPreview() returns it. Its image list is what the
// data urls in the frame are keyed on, and it is deliberately
// never pruned here: an image removed from the content is still
// needed to render the undo step that brings it back.
// options hasTitleHeading whether the frame's first <h1> is the page's
// own (editable) or one the build added (not)
// paragraphText the text a newly inserted paragraph carries
// onChange called after anything the toolbar reflects
// onEscape escape pressed while the frame has focus,
// which the page around it never sees
function createChapterEditSession(frameDocument, chapter, options) {
options = options || {};
let section = frameDocument.body ? frameDocument.body.firstElementChild : null;
if (!section) {
return null;
}
// The heading the build adds when the page has none naming itself. It is
// regenerated from the chapter title on every build, so editing it here
// would be editing something that is about to be overwritten - it stays
// outside the editable container, and the title is edited in the chapter
// list where it is actually stored.
// Checked rather than assumed: if it is not there, everything in the section
// is the page's own and all of it is editable, which is the safe way round -
// the other would hold back the chapter's first element from being edited.
let firstElement = section.firstElementChild;
let titleHeading = (!options.hasTitleHeading && firstElement &&
firstElement.tagName === 'H1') ? firstElement : null;
// Everything the user may change, and nothing else. Given a plain <div>
// rather than the <section> itself so that the container of the edit and the
// container the build writes are not the same element - one is ours to
// replace wholesale on undo, the other is not.
let editRoot = frameDocument.createElement('div');
editRoot.setAttribute('data-sae-edit-root', '');
let child = titleHeading ? titleHeading.nextSibling : section.firstChild;
while (child) {
let next = child.nextSibling;
editRoot.appendChild(child);
child = next;
}
section.appendChild(editRoot);
let style = frameDocument.createElement('style');
style.id = EDIT_STYLE_ID;
style.textContent = EDIT_STYLESHEET;
(frameDocument.head || frameDocument.body).appendChild(style);
let mode = 'view';
let dirty = false;
let undoStack = [];
// The element the pointer is actually over, and the one a click would
// remove, which is that element widened by the level below. Both are kept:
// widening again has to start from where the pointer is and not from what
// the last widening arrived at, or one keypress would climb two levels.
let hoverAnchor = null;
let hovered = null;
// How many steps up from the element under the pointer the removal target
// is. The same idea selector.js uses on a live page: what a reader sees as
// one thing - a figure, a callout, a nav block - is a wrapper several levels
// above the element the pointer is actually over. Kept across mousemoves
// rather than reset by them, so that widening is a decision about what kind
// of thing is being removed and not something to redo for every pixel.
let widenLevel = 0;
let typingBurstTimer = null;
function notifyChange() {
if (typeof options.onChange === 'function') {
options.onChange();
}
}
// ---- what the frame holds, as a chapter would hold it --------------------
function currentHtml() {
clearHover();
return restoreArchiveImageSrc(editRoot.innerHTML, chapter.images);
}
// The inverse. Parsed with DOMParser and adopted node by node rather than
// assigned to innerHTML: the extension stores flag an innerHTML assignment
// as an unsafe sink, and this one is fed by strings that came out of a
// contentEditable DOM. What is adopted here has been through the same html
// parser either way, and the sanitizer still has the last word at save time.
//
// Into a <template>, for the reason walkHtmlFragment() parses into one: it
// puts the parser in "in template" mode, where a fragment that begins inside
// a table - a <tr> or a <td>, which an edit inside one can leave at the
// front of the content - is kept rather than dropped.
function setCurrentHtml(html) {
let parsed = new DOMParser().parseFromString(
'<template>' + inlineArchiveImages(html, chapter.images) + '</template>',
'text/html');
let template = parsed.querySelector('template');
while (editRoot.firstChild) {
editRoot.removeChild(editRoot.firstChild);
}
let source = template ? template.content.firstChild : null;
while (source) {
editRoot.appendChild(frameDocument.importNode(source, true));
source = source.nextSibling;
}
// whatever was highlighted was in the content that has just been replaced
hovered = null;
hoverAnchor = null;
}
// ---- undo ----------------------------------------------------------------
function pushUndo() {
undoStack.push(currentHtml());
if (undoStack.length > MAX_UNDO_STEPS) {
undoStack.shift();
}
endTypingBurst();
}
function undo() {
if (undoStack.length === 0) {
return false;
}
setCurrentHtml(undoStack.pop());
endTypingBurst();
// Deliberately not cleared when the stack empties: the content is back
// where it started only if nothing was saved in between, and this flag
// is what decides whether closing the panel asks before discarding.
dirty = true;
notifyChange();
return true;
}
function endTypingBurst() {
if (typingBurstTimer !== null) {
clearTimeout(typingBurstTimer);
typingBurstTimer = null;
}
}
// A run of typing is one step, so that undo does not walk back a character
// at a time. beforeinput rather than input: the snapshot has to be of the
// text as it was before the run started.
function noteTyping() {
if (typingBurstTimer === null) {
pushUndo();
} else {
clearTimeout(typingBurstTimer);
}
typingBurstTimer = setTimeout(function() {
typingBurstTimer = null;
}, TYPING_BURST_MS);
if (!dirty) {
dirty = true;
notifyChange();
}
}
// ---- hovering, and what a click would remove -----------------------------
function clearHover() {
if (!hovered) {
return;
}
hovered.classList.remove(EDIT_HOVER_CLASS);
// classList leaves class="" behind on an element that had no class of
// its own, and the sanitizer keeps class attributes
if (hovered.getAttribute('class') === '') {
hovered.removeAttribute('class');
}
hovered = null;
}
// The element a click removes: the one under the pointer, widened by however
// many levels have been asked for, and never past the outermost thing that
// is the user's to remove - a direct child of the editable container.
function removalTargetFor(node) {
let element = node;
while (element && element.nodeType !== 1) {
element = element.parentNode;
}
if (!element || element === editRoot || !editRoot.contains(element)) {
return null;
}
for (let i = 0; i < widenLevel; i++) {
if (!element.parentNode || element.parentNode === editRoot) {
break;
}
element = element.parentNode;
}
return element;
}
function setHover(element) {
if (element === hovered) {
return;
}
clearHover();
hovered = element;
if (hovered) {
hovered.classList.add(EDIT_HOVER_CLASS);
}
notifyChange();
}
// What the toolbar shows so that a click is not a guess: the tag, and the
// generated class if it has one, of the element that would go.
function hoverLabel() {
if (!hovered) {
return '';
}
let label = hovered.tagName.toLowerCase();
let className = hovered.getAttribute('class') || '';
className = className.split(/\s+/).filter(function(name) {
return name !== '' && name !== EDIT_HOVER_CLASS;
})[0];
return className ? label + '.' + className : label;
}
function removeHovered() {
if (mode !== 'remove' || !hovered || !hovered.parentNode) {
return false;
}
let target = hovered;
// The class comes off before the snapshot, so what is stored is the
// chapter and not the chapter mid-hover
clearHover();
pushUndo();
target.parentNode.removeChild(target);
// the element the pointer was over went with it
hoverAnchor = null;
// Widening is about one removal. Keeping the level would make the next
// click, aimed at a sibling paragraph, take the block around it instead.
widenLevel = 0;
dirty = true;
notifyChange();
return true;
}
// ---- events inside the frame ---------------------------------------------
function onMouseMove(event) {
if (mode !== 'remove') {
return;
}
hoverAnchor = event.target;
setHover(removalTargetFor(hoverAnchor));
}
function onMouseLeave() {
if (mode === 'remove') {
hoverAnchor = null;
clearHover();
notifyChange();
}
}
function onClick(event) {
// A chapter is full of links, and a click on one in either editing mode
// means the element, not the destination. Navigation is blocked by the
// sandbox in any case, but a click that visibly does nothing is worse
// than one that is stopped.
if (mode === 'view') {
return;
}
if (mode === 'remove') {
event.preventDefault();
event.stopPropagation();
removeHovered();
return;
}
if (event.target && event.target.closest && event.target.closest('a')) {
event.preventDefault();
}
}
function onBeforeInput() {
if (mode === 'text') {
noteTyping();
}
}
// Pasting is the one way arbitrary markup - remote images, scripts, another
// page's whole layout - gets into a contentEditable. The sanitizer would
// strip all of it at save time, but not before the frame had fetched the
// remote images, and not before the user had spent a while arranging content
// that was never going to survive. Text is what the paste is for.
function onPaste(event) {
if (mode !== 'text' || !event.clipboardData) {
return;
}
event.preventDefault();
let text = event.clipboardData.getData('text/plain');
if (text === '') {
return;
}
noteTyping();
frameDocument.execCommand('insertText', false, text);
}
// A drop is a paste by another route, and the same answer: text. What is
// given up is dragging a paragraph from one place in the chapter to another,
// which remove mode and typing both cover; what is avoided is a drop from
// another window inserting its markup and its remote images, which the
// sanitizer would only undo after the browser had already fetched them.
function onDrop(event) {
if (mode !== 'text' || !event.dataTransfer) {
return;
}
event.preventDefault();
let text = event.dataTransfer.getData('text/plain');
if (text === '') {
return;
}
noteTyping();
frameDocument.execCommand('insertText', false, text);
}
function onKeyDown(event) {
// Keyboard events raised inside the frame never reach the page around
// it, so every shortcut the editor has has to be answered here as well.
if (event.key === 'Escape') {
if (typeof options.onEscape === 'function') {
event.preventDefault();
options.onEscape();
}
return;
}
if ((event.ctrlKey || event.metaKey) && (event.key === 'z' || event.key === 'Z')) {
// The browser's own undo would walk the contentEditable's history,
// which knows nothing about the elements removed around it. One
// stack, so that one shortcut means one thing.
event.preventDefault();
undo();
return;
}
if (mode === 'remove' && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
event.preventDefault();
if (event.key === 'ArrowUp') {
widen();
} else {
narrow();
}
}
}
frameDocument.addEventListener('mousemove', onMouseMove, true);
frameDocument.addEventListener('mouseleave', onMouseLeave, true);
frameDocument.addEventListener('click', onClick, true);
frameDocument.addEventListener('beforeinput', onBeforeInput, true);
frameDocument.addEventListener('paste', onPaste, true);
frameDocument.addEventListener('drop', onDrop, true);
frameDocument.addEventListener('keydown', onKeyDown, true);
// ---- widening ------------------------------------------------------------
// Both directions recompute from the element under the pointer rather than
// from the highlight, which is a result and not a position: walking up from
// the widened element would climb twice as fast, and walking back down from
// it is not defined at all, a parent having many children.
function reHover() {
if (hoverAnchor) {
setHover(removalTargetFor(hoverAnchor));
}
notifyChange();
}
function widen() {
if (mode !== 'remove') {
return;
}
// Stops at the outermost element that is the user's to remove. Counting
// past it would leave a level that does nothing and then has to be
// pressed back down before narrowing has any visible effect.
if (hovered && hovered.parentNode === editRoot) {
return;
}
if (widenLevel >= MAX_WIDEN_LEVELS) {
return;
}
widenLevel++;
reHover();
}
function narrow() {
if (mode !== 'remove' || widenLevel === 0) {
return;
}
widenLevel--;
reHover();
}
// ---- inserting text ------------------------------------------------------
// The outermost element the caret is inside, so that an inserted paragraph
// lands between two blocks rather than in the middle of one.
function selectionBlock() {
let selection = frameDocument.getSelection ? frameDocument.getSelection() : null;
if (!selection || selection.rangeCount === 0) {
return null;
}
let node = selection.getRangeAt(0).startContainer;
while (node && node !== editRoot) {
if (node.parentNode === editRoot) {
return node;
}
node = node.parentNode;
}
return null;
}
function insertParagraph(where) {
if (mode !== 'text') {
return null;
}
pushUndo();
let paragraph = frameDocument.createElement('p');
paragraph.textContent = options.paragraphText || '';
let anchor = selectionBlock();
if (anchor) {
editRoot.insertBefore(paragraph, where === 'before' ? anchor : anchor.nextSibling);
} else if (where === 'before') {
// no caret to insert beside - the ends of the chapter are the two
// places that need no selection to name
editRoot.insertBefore(paragraph, editRoot.firstChild);
} else {
editRoot.appendChild(paragraph);
}
dirty = true;
// Focus first, then the range: focusing an editable collapses whatever
// selection is in it. The placeholder is left selected so that typing
// replaces it.
editRoot.focus();
let range = frameDocument.createRange();
range.selectNodeContents(paragraph);
let selection = frameDocument.getSelection();
selection.removeAllRanges();
selection.addRange(range);
paragraph.scrollIntoView({block: 'nearest'});
notifyChange();
return paragraph;
}
// ---- modes ---------------------------------------------------------------
function setMode(nextMode) {
if (EDIT_MODES.indexOf(nextMode) < 0 || nextMode === mode) {
return;
}
clearHover();
endTypingBurst();
hoverAnchor = null;
widenLevel = 0;
mode = nextMode;
EDIT_MODES.forEach(function(name) {
frameDocument.body.classList.toggle(EDIT_MODE_CLASS_PREFIX + name, name === mode);
});
if (mode === 'text') {
editRoot.setAttribute('contenteditable', 'true');
// Enter inserts a paragraph rather than a div, and bold and italic
// are written as tags rather than as style attributes - the
// sanitizer keeps <b> and <i> and drops every style attribute, so
// without these two the formatting a user applies simply disappears
// at save time. Wrapped because these are legacy apis and a browser
// is free to refuse them; the default behaviour is then merely worse
// rather than broken.
try {
frameDocument.execCommand('defaultParagraphSeparator', false, 'p');
frameDocument.execCommand('styleWithCSS', false, false);
} catch (e) {
console.log('Error:', e);
}
editRoot.focus();
} else {
editRoot.removeAttribute('contenteditable');
}
notifyChange();
}
// ---- what the chapter becomes --------------------------------------------
// The one exit. Everything above changes a DOM; this is where that DOM
// becomes a chapter again, and it is the sanitizer that makes it one.
function serialize() {
let content = sanitizeChapterContent(currentHtml());
return {content: content, images: keepReferencedImages(content, chapter.images)};
}
function destroy() {
endTypingBurst();
frameDocument.removeEventListener('mousemove', onMouseMove, true);
frameDocument.removeEventListener('mouseleave', onMouseLeave, true);
frameDocument.removeEventListener('click', onClick, true);
frameDocument.removeEventListener('beforeinput', onBeforeInput, true);
frameDocument.removeEventListener('paste', onPaste, true);
frameDocument.removeEventListener('drop', onDrop, true);
frameDocument.removeEventListener('keydown', onKeyDown, true);
}
return {
getMode: function() { return mode; },
setMode: setMode,
widen: widen,
narrow: narrow,
getWidenLevel: function() { return widenLevel; },
hoverLabel: hoverLabel,
insertParagraph: insertParagraph,
undo: undo,
canUndo: function() { return undoStack.length > 0; },
isDirty: function() { return dirty; },
markSaved: function() { dirty = false; notifyChange(); },
serialize: serialize,
destroy: destroy
};
}

View file

@ -38,6 +38,64 @@ ul.chapterEditor-chapters-list li.placeholder {
font-family: sans-serif;
width: 85%;
}
/* The book-wide stylesheet, folded away until it is wanted: most books never
have one, and an open textarea here would push the chapter list off screen. */
#chapterEditor-bookCssHolder {
background-color: #f5f5f5;
padding: 8px 20px;
margin-bottom: 10px;
}
#chapterEditor-bookCssToggle {
font-weight: bold;
font-size: 15px;
padding: 3px 5px;
margin: 0;
}
#chapterEditor-bookCssHint {
font-size: 12px;
font-family: sans-serif;
color: #666;
margin-left: 10px;
}
#chapterEditor-bookCss {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 8px;
padding: 8px;
height: 140px;
/* css is written in columns; a proportional font makes a rule harder to
read than the terminal it would be written in */
font-family: monospace;
font-size: 13px;
border: solid 1px #ccc;
resize: vertical;
}
/* The same box for one chapter, inside the preview panel - the only place it can
be written, because it is written against what that chapter looks like. */
#chapterEditor-chapterCssHolder {
padding: 0 20px 5px 20px;
border-bottom: solid 1px #eee;
}
#chapterEditor-chapterCssHint {
font-size: 12px;
font-family: sans-serif;
color: #666;
padding: 5px 0;
}
#chapterEditor-chapterCss {
display: block;
width: 100%;
box-sizing: border-box;
padding: 8px;
height: 110px;
font-family: monospace;
font-size: 13px;
border: solid 1px #ccc;
resize: vertical;
}
ul, ul.chapterEditor-chapters-list {
width: 100%;
display: block;
@ -171,6 +229,108 @@ ul, ul.chapterEditor-chapters-list {
/*background-color: black;*/
}
/* The chapter preview, which covers the editor rather than sitting in it: the
chapter needs the width, and only one is ever rendered at a time. */
#chapterEditor-previewPanel {
position: fixed;
z-index: 3;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
}
#chapterEditor-previewContent {
background-color: #fff;
width: 70%;
height: 90%;
margin: 2% auto;
display: flex;
flex-direction: column;
}
#chapterEditor-previewHeader {
display: block;
overflow: hidden;
padding: 15px 20px 0 20px;
}
#chapterEditor-previewTitle {
font-size: 18px;
font-weight: bold;
font-family: sans-serif;
}
#chapterEditor-previewNote {
padding: 5px 20px 0 20px;
font-size: 12px;
font-family: sans-serif;
color: #666;
}
/* The editing toolbar. The modes are exclusive and the active one is stated
rather than implied, because which one is on decides what a click in the
frame below does. */
#chapterEditor-previewToolbar {
padding: 5px 20px;
border-bottom: solid 1px #eee;
display: block;
overflow: hidden;
}
.chapterEditor-toolbar-group {
display: inline;
margin-right: 15px;
}
.chapterEditor-toolbar-button {
border: solid 1px #ddd;
border-radius: 3px;
margin: 0 3px;
}
.chapterEditor-toolbar-button:disabled {
color: #aaa;
border-color: #eee;
cursor: default;
}
.chapterEditor-toolbar-button:disabled:hover {
background-color: rgba(0, 0, 0, 0);
color: #aaa;
}
.chapterEditor-toolbar-button.chapterEditor-mode-active {
background-color: #000;
color: #fff;
border-color: #000;
}
.chapterEditor-toolbar-button.chapterEditor-toolbar-save {
background-color: yellow;
}
.chapterEditor-toolbar-button.chapterEditor-toolbar-save:disabled {
background-color: rgba(0, 0, 0, 0);
}
#chapterEditor-previewHint {
padding: 5px 20px 10px 20px;
font-size: 12px;
font-family: monospace;
color: #333;
/* the hint appears and disappears with the mode; without a reserved line the
frame below it would jump every time it changed */
min-height: 16px;
}
#chapterEditor-previewBody {
flex: 1;
/* a flex item refuses to shrink below its content without this, and the
frame would grow the panel past the bottom of the window */
min-height: 0;
padding: 0 20px 20px 20px;
}
#chapterEditor-previewFrame {
width: 100%;
height: 100%;
border: solid 1px #ddd;
background-color: #fff;
}
#chapterEditor-previewEmpty {
font-family: sans-serif;
color: #666;
}
@media (max-width: 1700px) {
.chapterEditor-chapter-item {
line-height: 30px;

View file

@ -49,6 +49,59 @@ function showEditor() {
titleHolder.appendChild(ebookTilte);
modalList.appendChild(titleHolder);
/////////////////////
// The book-wide stylesheet, written into ebook.css, which every chapter and
// the table of contents link.
//
// This is not the "Custom Styles" editor in the popup, and the labels say so:
// that one holds a stylesheet per site and is applied to a page while it is
// being captured, to hide the parts of it that should not be in a book at
// all. This one is applied to the book that comes out. A user who has met
// the first has every reason to assume this is more of it.
//
// Folded away by default. Most books never have one, and an empty textarea
// between the title and the chapters would push the list - the thing this
// page is for - off the first screen.
var bookCssHolder = document.createElement('div');
bookCssHolder.id = 'chapterEditor-bookCssHolder';
var bookCssToggle = document.createElement('button');
bookCssToggle.id = 'chapterEditor-bookCssToggle';
bookCssToggle.className = 'chapterEditor-text-button';
bookCssToggle.onclick = function() {
setBookCssOpen(bookCssArea.style.display === 'none');
};
var bookCssHint = document.createElement('span');
bookCssHint.id = 'chapterEditor-bookCssHint';
bookCssHint.innerText = chrome.i18n.getMessage('bookCssHint');
var bookCssArea = document.createElement('textarea');
bookCssArea.id = 'chapterEditor-bookCss';
bookCssArea.spellcheck = false;
bookCssArea.placeholder = chrome.i18n.getMessage('bookCssPlaceholder');
getBookCss(function (css) {
bookCssArea.value = css;
});
// The preview reads this box rather than storage, so a rule takes effect as
// it is typed and without being saved first - which is the only way to write
// css against content you can see.
bookCssArea.oninput = function() {
applyBookCssToPreview();
};
function setBookCssOpen(open) {
bookCssArea.style.display = open ? 'block' : 'none';
bookCssToggle.innerText = (open ? '▾ ' : '▸ ') +
chrome.i18n.getMessage('bookCssLabel');
}
setBookCssOpen(false);
bookCssHolder.appendChild(bookCssToggle);
bookCssHolder.appendChild(bookCssHint);
bookCssHolder.appendChild(bookCssArea);
modalList.appendChild(bookCssHolder);
function createChapterList(allPages) {
allPagesRef = allPages;
@ -77,7 +130,7 @@ function showEditor() {
var buttons = document.createElement('span');
var previewButton = document.createElement('button');
previewButton.innerText = chrome.i18n.getMessage('rawPreview');
previewButton.innerText = chrome.i18n.getMessage('preview');
previewButton.className = 'chapterEditor-text-button';
previewButton.onclick = previewListItem(i);
@ -121,8 +174,7 @@ function showEditor() {
var saveButton = document.createElement('button');
saveButton.onclick = function() {
var newChapters = saveChanges();
prepareEbook(newChapters);
prepareEbook(saveChanges());
};
saveButton.innerText = chrome.i18n.getMessage('generateEbook');
saveButton.className = 'chapterEditor-footer-button chapterEditor-float-right chapterEditor-generate-button';
@ -138,6 +190,188 @@ function showEditor() {
modalFooter.appendChild(buttons);
/////////////////////
// Chapter preview and editor
//
// Built when it is asked for and thrown away when it closes. A chapter
// carries its images as base64 and the preview turns them into data urls, so
// a book of image-heavy chapters would otherwise end up holding every one of
// them decoded at once.
//
// The frame is where the chapter is edited as well as shown - the rendering
// is the editing surface, so what is removed is what was seen to be removed.
// Everything that changes the content lives in chapterContentEditor.js; what
// is here is the toolbar that drives it and the path from an edit to storage.
var previewPanel = document.createElement('div');
previewPanel.id = 'chapterEditor-previewPanel';
previewPanel.style.display = 'none';
var previewContent = document.createElement('div');
previewContent.id = 'chapterEditor-previewContent';
var previewHeader = document.createElement('div');
previewHeader.id = 'chapterEditor-previewHeader';
var previewTitle = document.createElement('span');
previewTitle.id = 'chapterEditor-previewTitle';
var previewCloseButton = document.createElement('button');
previewCloseButton.innerText = 'X';
previewCloseButton.className = 'chapterEditor-text-button chapterEditor-float-right';
previewCloseButton.onclick = closePreview;
previewHeader.appendChild(previewTitle);
previewHeader.appendChild(previewCloseButton);
// The one thing an iframe cannot show: pagination, fonts and margins belong
// to the reading system and differ between devices.
var previewNote = document.createElement('div');
previewNote.id = 'chapterEditor-previewNote';
previewNote.innerText = chrome.i18n.getMessage('previewNote');
var previewBody = document.createElement('div');
previewBody.id = 'chapterEditor-previewBody';
// The chapter being previewed: which record it is, the chapter as the build
// normalized it, the frame showing it, and the editing session over that
// frame. All empty while the panel is closed - and editSession alone can be
// null with a chapter rendered, because a preview that cannot be edited is
// still worth reading.
var previewIndex = -1;
var previewChapter = null;
var previewFrame = null;
var editSession = null;
// What this chapter's own css was when the panel opened, so that "has it
// changed" is answered by comparison rather than by a flag that has to be
// cleared in the right places.
var chapterCssOriginal = '';
function toolbarButton(id, messageKey, onClick) {
var button = document.createElement('button');
button.id = id;
button.className = 'chapterEditor-text-button chapterEditor-toolbar-button';
button.innerText = chrome.i18n.getMessage(messageKey);
button.onclick = onClick;
return button;
}
var previewToolbar = document.createElement('div');
previewToolbar.id = 'chapterEditor-previewToolbar';
// The modes are exclusive and the toolbar is the only way into them: an
// editor where clicking sometimes follows a link and sometimes deletes a
// paragraph, depending on nothing visible, is one that deletes paragraphs by
// accident.
var modeButtons = {
view: toolbarButton('chapterEditor-modeView', 'editModeView', setMode('view')),
remove: toolbarButton('chapterEditor-modeRemove', 'editModeRemove', setMode('remove')),
text: toolbarButton('chapterEditor-modeText', 'editModeText', setMode('text'))
};
var widenButton = toolbarButton('chapterEditor-widen', 'selectParent', function() {
if (editSession) {
editSession.widen();
}
});
var narrowButton = toolbarButton('chapterEditor-narrow', 'selectChild', function() {
if (editSession) {
editSession.narrow();
}
});
var insertBeforeButton = toolbarButton('chapterEditor-insertBefore', 'insertParagraphBefore',
function() {
if (editSession) {
editSession.insertParagraph('before');
}
});
var insertAfterButton = toolbarButton('chapterEditor-insertAfter', 'insertParagraphAfter',
function() {
if (editSession) {
editSession.insertParagraph('after');
}
});
var undoButton = toolbarButton('chapterEditor-undo', 'undoEdit', function() {
if (editSession) {
editSession.undo();
}
});
var chapterCssButton = toolbarButton('chapterEditor-chapterCssToggle', 'chapterCssLabel',
function() {
setChapterCssOpen(chapterCssHolder.style.display === 'none');
});
var saveChapterButton = toolbarButton('chapterEditor-saveChapter', 'saveChapter',
saveChapterEdits);
saveChapterButton.classList.add('chapterEditor-toolbar-save');
var previewGroups = {};
function toolbarGroup(id, buttons) {
var group = document.createElement('span');
group.id = id;
group.className = 'chapterEditor-toolbar-group';
buttons.forEach(function(button) {
group.appendChild(button);
});
previewToolbar.appendChild(group);
previewGroups[id] = group;
return group;
}
toolbarGroup('chapterEditor-modeGroup', [modeButtons.view, modeButtons.remove, modeButtons.text]);
toolbarGroup('chapterEditor-removeGroup', [widenButton, narrowButton]);
toolbarGroup('chapterEditor-textGroup', [insertBeforeButton, insertAfterButton]);
toolbarGroup('chapterEditor-cssGroup', [chapterCssButton]);
toolbarGroup('chapterEditor-editGroup', [undoButton, saveChapterButton]);
// This chapter's own css, appended to the stylesheet extraction captured for
// it. Where the book stylesheet says what the book looks like, this is for
// the one chapter whose source page needs an exception - and it is where it
// is because it can only be written while looking at that chapter.
var chapterCssHolder = document.createElement('div');
chapterCssHolder.id = 'chapterEditor-chapterCssHolder';
var chapterCssHint = document.createElement('div');
chapterCssHint.id = 'chapterEditor-chapterCssHint';
chapterCssHint.innerText = chrome.i18n.getMessage('chapterCssHint');
var chapterCssArea = document.createElement('textarea');
chapterCssArea.id = 'chapterEditor-chapterCss';
chapterCssArea.spellcheck = false;
chapterCssArea.placeholder = chrome.i18n.getMessage('chapterCssPlaceholder');
chapterCssArea.oninput = function() {
applyChapterCssToPreview();
updateToolbar();
};
chapterCssHolder.appendChild(chapterCssHint);
chapterCssHolder.appendChild(chapterCssArea);
function setChapterCssOpen(open) {
chapterCssHolder.style.display = open ? 'block' : 'none';
chapterCssButton.classList.toggle('chapterEditor-mode-active', open);
}
setChapterCssOpen(false);
// What the current mode does, and - in remove mode - what a click would take
// with it. Naming the element that is about to go is the difference between
// removing a stray advertisement and removing the article around it.
var previewHint = document.createElement('div');
previewHint.id = 'chapterEditor-previewHint';
previewContent.appendChild(previewHeader);
previewContent.appendChild(previewToolbar);
previewContent.appendChild(chapterCssHolder);
previewContent.appendChild(previewNote);
previewContent.appendChild(previewHint);
previewContent.appendChild(previewBody);
previewPanel.appendChild(previewContent);
previewPanel.onclick = function(event) {
if (event.target === previewPanel) {
closePreview();
}
};
/////////////////////
var modal = document.createElement('div');
@ -149,6 +383,11 @@ function showEditor() {
modal.appendChild(modalContent);
body.appendChild(modal);
// after the modal, and above it - the preview covers the list it was opened
// from rather than sitting inside it
body.appendChild(previewPanel);
// the toolbar of a panel holding no chapter: every editing control hidden
updateToolbar();
modal.style.display = "none";
modal.style.position = 'fixed';
@ -177,6 +416,12 @@ function showEditor() {
document.onkeydown = function(evt) {
evt = evt || window.event;
if (evt.keyCode == 27) {
// the preview sits on top of the editor, so escape leaves that
// first - otherwise it would close the whole tab from behind it
if (isPreviewOpen()) {
closePreview();
return;
}
closeModal();
}
};
@ -196,22 +441,261 @@ function showEditor() {
function previewListItem(atIndex) {
return function() {
alert(allPagesRef[atIndex].content.trim().replace(/<[^>]+>/gi, '').replace(/\s+/g, ' ').substring(0, 1000) + ' ...');
var page = allPagesRef[atIndex];
// The title as the row shows it now, not as storage still has it: it
// becomes the chapter's heading when the page has none of its own, so
// a rename that has not been saved yet has to be visible here.
var titleInput = document.getElementById('text' + atIndex);
var title = titleInput && titleInput.value.trim() !== '' ?
titleInput.value : page.title;
openPreview(atIndex, Object.assign({}, page, {title: title}));
};
}
function prepareEbook(newChapters) {
function isPreviewOpen() {
return previewPanel.style.display !== 'none';
}
function openPreview(atIndex, page) {
// From the box, not from storage: an unsaved rule has to be visible here
// for the same reason an unsaved rename is.
var preview = buildChapterPreview(page, {bookCss: bookCssArea.value});
// whatever the last preview decoded goes now, before the next one is
// built rather than when the panel is next closed
clearPreview();
previewIndex = atIndex;
previewChapter = preview.chapter;
previewTitle.innerText = page.title;
chapterCssOriginal = typeof page.customCss === 'string' ? page.customCss : '';
chapterCssArea.value = chapterCssOriginal;
// opened for a chapter that already has one, so it is not a box the user
// has to remember they filled in
setChapterCssOpen(chapterCssOriginal.trim() !== '');
if (preview.html === '') {
// a record the build would skip as well
var empty = document.createElement('p');
empty.id = 'chapterEditor-previewEmpty';
empty.innerText = chrome.i18n.getMessage('previewEmpty');
previewBody.appendChild(empty);
} else {
var frame = document.createElement('iframe');
frame.id = 'chapterEditor-previewFrame';
// No allow-scripts, which is the flag that matters: chapter content
// is script-free by construction - the sanitizer keeps no script, no
// event handler and no javascript: url - and the sandbox is what
// still holds the day one of those stops being true. allow-same-
// origin is not a hole in that: it grants the frame's content
// nothing, there being no content that can run, and it is what lets
// this page reach into the document to edit it.
frame.setAttribute('sandbox', 'allow-same-origin');
frame.onload = function() {
startEditSession(frame, preview);
};
frame.srcdoc = preview.html;
previewBody.appendChild(frame);
previewFrame = frame;
}
updateToolbar();
previewPanel.style.display = 'block';
}
// The two boxes reach the open frame by replacing the <style> the preview
// document was built with, rather than by rebuilding that document: a rebuilt
// srcdoc reloads the frame, and reloading it would throw away the edit
// session and every change in it that has not been saved.
function previewDocument() {
try {
if (newChapters.length === 0) {
alert(chrome.i18n.getMessage('emptyBookWarning'));
return;
return previewFrame ? previewFrame.contentDocument : null;
} catch (e) {
return null;
}
}
function applyBookCssToPreview() {
setPreviewStyle(previewDocument(), PREVIEW_BOOK_CSS_ID, bookCssArea.value);
}
// The chapter's <style> holds the whole stylesheet the build will write for
// it - what extraction captured and what the user is typing - so it is
// assembled the same way here.
function applyChapterCssToPreview() {
if (!previewChapter) {
return;
}
setPreviewStyle(previewDocument(), PREVIEW_CHAPTER_CSS_ID, chapterStyleContent({
styleFileContent: previewChapter.styleFileContent,
customCss: chapterCssArea.value
}));
}
function isChapterCssDirty() {
return previewChapter !== null && chapterCssArea.value !== chapterCssOriginal;
}
function startEditSession(frame, preview) {
try {
editSession = createChapterEditSession(frame.contentDocument, preview.chapter, {
hasTitleHeading: preview.hasTitleHeading,
paragraphText: chrome.i18n.getMessage('insertedParagraphText'),
onChange: updateToolbar,
// escape inside the frame never reaches the page's own handler
onEscape: closePreview
});
} catch (e) {
// Editing is the part that can fail here - the preview itself is
// already rendered and is worth keeping without it.
console.log('Error:', e);
editSession = null;
}
updateToolbar();
}
function clearPreview() {
if (editSession) {
editSession.destroy();
editSession = null;
}
previewIndex = -1;
previewChapter = null;
previewFrame = null;
chapterCssOriginal = '';
chapterCssArea.value = '';
while (previewBody.firstChild) {
previewBody.removeChild(previewBody.firstChild);
}
}
function closePreview() {
// Edits live in the frame - and the chapter's css in its box - until they
// are saved, and both go when the panel closes. Nothing else in this page
// warns before discarding work, because nothing else in it holds any.
if (((editSession && editSession.isDirty()) || isChapterCssDirty()) &&
!confirm(chrome.i18n.getMessage('discardEditsConfirm'))) {
return;
}
previewPanel.style.display = 'none';
clearPreview();
updateToolbar();
}
// The edited chapter, through the sanitizer and into storage. The record the
// page is holding is updated first: it is what a build reads, and what the
// preview would be rebuilt from.
//
// The content and this chapter's css are saved together, by this one button,
// because they are the two things the panel holds and a user who has changed
// both would otherwise have to find two ways to keep them. The css does not
// need the edit session - it is a field on the record, not a serialization of
// a DOM - so a chapter that failed to become editable can still be styled.
function saveChapterEdits() {
if (previewIndex < 0 || !allPagesRef[previewIndex]) {
return;
}
try {
if (editSession) {
var edited = editSession.serialize();
allPagesRef[previewIndex].content = edited.content;
allPagesRef[previewIndex].images = edited.images;
}
buildEbookFromChapters();
allPagesRef[previewIndex].customCss = chapterCssArea.value;
chapterCssOriginal = chapterCssArea.value;
saveChanges();
if (editSession) {
editSession.markSaved();
}
// last, because updateToolbar() writes the mode's own hint over it
updateToolbar();
setHint(chrome.i18n.getMessage('chapterSaved'));
} catch (e) {
console.log('Error:', e);
}
}
function setHint(text) {
previewHint.innerText = text;
}
// The toolbar says what mode the frame is in, what is available in it, and
// what a click in remove mode would take. Rebuilt from the session rather
// than tracked alongside it, so the two cannot disagree.
function updateToolbar() {
var mode = editSession ? editSession.getMode() : 'view';
var editable = editSession !== null;
// Styling a chapter needs the chapter, not the edit session: it writes a
// field on the record rather than serializing the frame's DOM.
var hasChapter = previewChapter !== null;
Object.keys(modeButtons).forEach(function(name) {
modeButtons[name].classList.toggle('chapterEditor-mode-active',
editable && name === mode);
modeButtons[name].disabled = !editable;
});
// Only the controls the current mode can act on, rather than the whole
// toolbar greyed differently in each mode
previewGroups['chapterEditor-removeGroup'].style.display =
mode === 'remove' ? 'inline' : 'none';
previewGroups['chapterEditor-textGroup'].style.display =
mode === 'text' ? 'inline' : 'none';
previewGroups['chapterEditor-cssGroup'].style.display =
hasChapter ? 'inline' : 'none';
previewGroups['chapterEditor-editGroup'].style.display =
hasChapter ? 'inline' : 'none';
if (!hasChapter) {
setChapterCssOpen(false);
}
narrowButton.disabled = !editSession || editSession.getWidenLevel() === 0;
undoButton.disabled = !editSession || !editSession.canUndo();
undoButton.style.display = editable ? 'inline' : 'none';
saveChapterButton.disabled =
!((editSession && editSession.isDirty()) || isChapterCssDirty());
if (!editable) {
setHint('');
return;
}
if (mode === 'remove') {
var label = editSession.hoverLabel();
setHint(label === '' ? chrome.i18n.getMessage('removeModeHint') :
chrome.i18n.getMessage('removeModeTarget') + ' <' + label + '>');
return;
}
setHint(chrome.i18n.getMessage(mode === 'text' ? 'textModeHint' : 'viewModeHint'));
}
function setMode(name) {
return function() {
if (editSession) {
editSession.setMode(name);
}
};
}
// Builds from the chapters this page is holding, not from storage.
// saveChanges() has only just started writing them, and the write is
// asynchronous: reading them back here used to be a race with it, and every
// edit that raises the write frequency makes it more likely to be lost.
// The identifier is a separate read because nothing on this page writes it.
function prepareEbook(changes) {
try {
if (!changes || changes.chapters.length === 0) {
alert(chrome.i18n.getMessage('emptyBookWarning'));
return;
}
getEbookUuid(function (uuid) {
buildEbook(changes.chapters,
{title: changes.title, uuid: uuid, css: changes.bookCss});
});
} catch (e) {
console.log('Error:', e);
}
}
// Persists what the page currently shows, and hands it back so that a build
// can use it without waiting for storage.
function saveChanges() {
var newChapters = [];
var newEbookTitle = ebookTilte.value;
@ -236,8 +720,9 @@ function showEditor() {
}
saveEbookTitle(newEbookTitle);
saveBookCss(bookCssArea.value);
saveEbookPages(newChapters);
return newChapters;
return {chapters: newChapters, title: newEbookTitle, bookCss: bookCssArea.value};
} catch (e) {
console.log('Error:', e);
}

View file

@ -0,0 +1,194 @@
// Renders one buffered chapter as the packaged book will show it.
//
// The document this builds is assembled by the same functions the build stage
// uses - normalizeChapters(), buildChapterOutline(), getChapterBody() - so what
// the preview shows is the chapter as it will be written, not a second rendering
// of the same content that can drift from it. Only the two things an archive
// resolves for itself are done differently here: the stylesheets a chapter links
// are inlined, and image references into the archive become data urls.
//
// Data urls rather than blob urls, which would be revocable and cheaper: a blob
// url is only readable from the origin that created it, and the frame this feeds
// is sandboxed - it holds that privilege only because the editor reaches into
// it, and content that is script-free by construction can do nothing with it.
//
// Both of those rewrites are reversible, and the reverse is what the editor
// needs: what comes back out of the frame has to be the chapter again, not the
// rendering of it. inlineArchiveImages() and restoreArchiveImageSrc() are that
// pair.
//
// What the preview cannot show is pagination, fonts and margins - those are the
// reading system's, and they differ between devices.
// The reference an <img> in a chapter carries, as extraction's getImageSrc()
// writes it: a file in the archive's images folder, possibly with leading path
// segments. Deliberately the same shape sanitizeHtml.js keeps and saveEbook.js
// checks against the image records.
var PREVIEW_IMAGE_SRC_REGEX = /^(?:\.{1,2}\/)*images\/([^/?#]+)(?:[?#].*)?$/i;
function previewImageDataUrl(image) {
let type = getImageType(image.filename);
// normalizeChapters() has already dropped the images this cannot answer for
// and the <img> tags pointing at them, so this is a guard, not a path.
return type === '' ? '' : 'data:image/' + type + ';base64,' + image.data;
}
// Which file in the archive a decoded <img> came from, written beside the data
// url that replaced its src. The bytes cannot answer this on their own: two
// files with different names can hold the same picture - the same asset reached
// by two urls is downloaded twice and named twice - and their data urls are then
// identical, so a map from data url back to filename would answer one of them
// for both and quietly move a chapter's reference onto the other file.
//
// The attribute never reaches storage: restoreArchiveImageSrc() takes it off
// again, and sanitizeChapterContent() reads no data attribute off an <img>
// either way.
var ARCHIVE_IMAGE_MARKER_ATTR = 'data-sae-src';
// Every <img> pointing into the archive gets the bytes the archive would hold.
// One that points anywhere else is left alone: it is a remote image, which the
// reading system fetches, and which the preview should therefore try to fetch
// too rather than silently blank out.
function inlineArchiveImages(content, images) {
let byFilename = Object.create(null);
(images || []).forEach(function(image) {
byFilename[image.filename] = image;
});
return String(content || '').replace(/<img\b[^>]*>/gi, function(tag) {
let src = (tag.match(/\ssrc="([^"]*)"/i) || [])[1];
if (src === undefined) {
return tag;
}
let match = src.match(PREVIEW_IMAGE_SRC_REGEX);
if (!match) {
return tag;
}
let image = byFilename[match[1]];
let dataUrl = image ? previewImageDataUrl(image) : '';
// A reference with no bytes behind it renders as a broken image icon,
// which reads as a bug in the preview rather than as what it is. The
// build removes these tags outright; so does this.
if (dataUrl === '') {
return '';
}
// A marker from an earlier pass would be a second one on the same tag,
// and the first is not necessarily the one this pass is writing
let marked = tag.replace(/\s+data-sae-src="[^"]*"/gi, '')
.replace(/^<img\b/i, '<img ' + ARCHIVE_IMAGE_MARKER_ATTR + '="' +
escapeXMLChars(image.filename) + '"');
return marked.replace(/\ssrc="[^"]*"/i, ' src="' + dataUrl + '"');
});
}
// Where an image reference points from a chapter: chapters are written into
// pages/ and images into images/, both at the root of the archive. Extraction
// writes exactly this prefix, and it is what a reference restored here gets,
// whatever spelling it had before - a chapter buffered by an older version may
// carry "images/x.png", which resolves to nothing from inside pages/.
var ARCHIVE_IMAGE_PREFIX = '../images/';
// The inverse of inlineArchiveImages(): every <img> the preview decoded points
// into the archive again, and loses the marker saying which file it points at.
//
// An <img> without a marker was not decoded here - a remote image the chapter
// always had, or one an editing user brought in - and is left exactly as it
// stands, for sanitizeChapterContent() to accept or drop on its own terms. A
// marker naming an image the chapter no longer carries keeps its data url, which
// the sanitizer then refuses: the reference has nothing behind it either way,
// and dropping the tag is what the build does with those.
function restoreArchiveImageSrc(content, images) {
let known = Object.create(null);
(images || []).forEach(function(image) {
// keyed as the attribute holds it, so that a filename needing escaping
// is matched without decoding anything back
known[escapeXMLChars(image.filename)] = image.filename;
});
return String(content || '').replace(/<img\b[^>]*>/gi, function(tag) {
let marker = (tag.match(/\sdata-sae-src="([^"]*)"/i) || [])[1];
if (marker === undefined) {
return tag;
}
let restored = tag.replace(/\s+data-sae-src="[^"]*"/gi, '');
let filename = known[marker];
if (filename === undefined) {
return restored;
}
return restored.replace(/\ssrc="[^"]*"/i,
' src="' + ARCHIVE_IMAGE_PREFIX + escapeXMLChars(filename) + '"');
});
}
// Chapter css reaches the archive as its own file, where nothing can end it
// early. Inlined into <style>, the first "</style" in it would close the element
// and the rest would be printed as text. Extraction only ever writes declarations
// from its own allowlist, so it could not trigger this - but the css a user types
// in the editor goes through here too, and that is exactly the string this is
// for.
function previewStyleText(css) {
return String(css || '').replace(/<\/(style)/gi, '<\\/$1');
}
// The two stylesheets the preview inlines, named so that a caller editing css
// can replace one of them in the open frame instead of rebuilding the document.
// Rebuilding it would reload the frame, and reloading it would throw away the
// edit session and every unsaved change in it.
var PREVIEW_BOOK_CSS_ID = 'sae-preview-book-css';
var PREVIEW_CHAPTER_CSS_ID = 'sae-preview-chapter-css';
// Live update of one of them. textContent rather than innerHTML, so the string
// cannot become markup and previewStyleText() has nothing to guard against here.
function setPreviewStyle(frameDocument, styleId, css) {
if (!frameDocument) {
return;
}
let element = frameDocument.getElementById(styleId);
if (element) {
element.textContent = sanitizeStylesheet(css);
}
}
// The chapter as a standalone html document, ready for an iframe's srcdoc, and
// beside it the two things a caller editing that document needs to know: the
// chapter as the build normalized it - which is the image list the rewrites
// above are keyed on - and whether the <h1> the document opens with is the
// page's own or one the build added.
//
// Returns html: '' when there is nothing to render - a record the build skips.
function buildChapterPreview(page, options) {
options = options || {};
// The build's own normalization: images whose type never resolved, and
// references to bytes that never arrived, are dropped from the content here
// exactly as they will be dropped from the book. A preview showing a picture
// the reader will not get is worse than one showing the gap.
let chapter = normalizeChapters([page])[0];
if (!chapter) {
return {html: '', chapter: null, hasTitleHeading: false};
}
let outline = buildChapterOutline(chapter, 0);
let body = inlineArchiveImages(getChapterBody(chapter, outline), chapter.images);
let language = getPageMetadata(chapter).lang || 'en';
let direction = getPageMetadata(chapter).dir;
let html = '<!DOCTYPE html>' +
'<html lang="' + language + '"' + dirAttribute(direction) + '>' +
'<head>' +
'<meta charset="utf-8">' +
'<title>' + escapeXMLChars(chapter.title) + '</title>' +
// In link order, so the cascade is the archive's: the book-wide
// stylesheet first, then the chapter's own - which is the captured css
// and the user's chapter css as one file, exactly as the build writes it.
'<style id="' + PREVIEW_BOOK_CSS_ID + '">' +
previewStyleText(sanitizeStylesheet(options.bookCss)) + '</style>' +
'<style id="' + PREVIEW_CHAPTER_CSS_ID + '">' +
previewStyleText(chapterStyleContent(chapter)) + '</style>' +
'</head>' +
body +
'</html>';
return {html: html, chapter: chapter, hasTitleHeading: outline.hasTitleHeading};
}
// The document on its own, for callers that only render it.
function buildChapterPreviewDocument(page, options) {
return buildChapterPreview(page, options).html;
}

View file

@ -11,7 +11,16 @@
the page able to read everything it did. It is an extension page now. -->
<script src="libs/jszip.js"></script>
<script src="utils.js"></script>
<!-- the same sanitizer the content script runs, so that content this page
writes satisfies the invariants the build stage relies on -->
<script src="sanitizeHtml.js"></script>
<script src="saveEbook.js"></script>
<!-- renders one chapter the way the build writes it, for the preview panel -->
<script src="chapterPreview.js"></script>
<!-- turns that rendering into the editing surface: removing elements and
editing text happen in the frame, and everything that comes back out of
it goes through the sanitizer above before it is stored -->
<script src="chapterContentEditor.js"></script>
<script src="chapterEditor.js"></script>
</body>
</html>

View file

@ -13,82 +13,11 @@ var extractedImages = [];
// in every list row - would otherwise download, store and index it once per
// <img>, at full size each time.
var imageFileNames = new Map();
var MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
// MathML Core is what every WebKit and Chromium reading system implements -
// Apple Books, Thorium, Kobo, anything built on either engine - and it dropped a
// large part of what MathML 3 allowed. Everything Core removed is deliberately
// missing from this list and rewritten by normalizeMathMl() instead, because
// passing one of them through means a reader draws nothing where the element
// was:
//
// mfenced expanded into the mrow of <mo> fences the MathML 3 spec defines
// it to be equivalent to. A Core reader lays out an mfenced's
// children with no brackets at all, so "f(x)" comes out as "fx" -
// and older MathJax output is full of it.
// mlabeledtr renamed to <mtr>, keeping the label as its first cell.
// Unwrapping it would put <mtd>s straight into the <mtable>.
// menclose unwrapped: the box or strikethrough it draws is lost either
// way, and its content is the part worth keeping.
// mstack, msgroup, mlongdiv
// unwrapped. These lay digits out in a grid for long division;
// without the layout the digits are all that is left, but they
// are at least still in reading order.
// mscarries, mscarry
// dropped with their content - a carried digit annotates a
// position in that grid, and outside it it is a stray number in
// the middle of another number.
//
// mprescripts and none are the other direction: both are required by
// mmultiscripts and neither was ever allowed, so a prescripted symbol lost the
// marker saying where its prescripts begin.
var mathMLTags = [
'math', 'maction', 'merror', 'mfrac', 'mglyph', 'mi', 'mmultiscripts', 'mn', 'mo',
'mover', 'mpadded', 'mphantom', 'mprescripts', 'mroot', 'mrow', 'ms', 'mspace',
'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr',
'munder', 'munderover', 'none', 'semantics'
];
// blockquote was missing rather than excluded, and it is not a tag whose absence
// shows up as a gap: it is not in strippedContentTags either, so a quotation was
// unwrapped - the tag went, the words stayed - and ran into the paragraph before
// it with nothing to say where the quote started or ended.
var allowedTags = [
'address', 'article', 'aside', 'blockquote', 'footer', 'header', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'hgroup', 'nav', 'section', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure', 'hr', 'li',
'main', 'ol', 'p', 'pre', 'ul', 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data',
'dfn', 'em', 'i', 'img', 'kbd', 'mark', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'small', 'span',
'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr', 'del', 'ins', 'caption', 'col', 'colgroup',
'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'
// TODO ?
// ,'form', 'button'
// The tag allowlists, the attribute filters and parseHTML() itself live in
// sanitizeHtml.js, which this file requires: the chapter editor writes content
// through the same sanitizer, and it cannot load a content script.
// TODO svg support ?
// , 'svg', 'g', 'path', 'line', 'circle', 'text'
].concat(mathMLTags);
// const svgTags = ['svg', 'g', 'path', 'line', 'circle', 'text']
// Allowed tags that have no content and must be written self-closed, otherwise
// the generated page is not well formed XML
var voidTags = ['br', 'col', 'hr', 'img', 'wbr'];
// Tags that are dropped together with everything inside them. Any other tag
// that is not in allowedTags is unwrapped instead - the tag goes, its text stays.
var strippedContentTags = [
'script', 'style', 'noscript', 'template', 'iframe', 'object', 'embed', 'applet',
'head', 'title', 'meta', 'link', 'base',
'textarea', 'select', 'option', 'optgroup', 'input',
'audio', 'video', 'canvas', 'svg', 'map', 'area',
// <semantics> keeps its presentation MathML and loses its annotations. An
// annotation is an alternative encoding of the same formula - almost always
// the TeX it was typeset from - and a conforming reader renders only the
// first child of <semantics>, so it is never meant to be seen. But
// <annotation> was in neither list, so it was unwrapped instead and its raw
// TeX printed as visible text right beside the rendered formula. That is the
// single most visible MathML bug on a KaTeX page.
//
// The TeX itself is not thrown away: normalizeMathMl() lifts it into the
// <math alttext> first, where it is a fallback rather than a duplicate.
'annotation', 'annotation-xml',
'mscarries', 'mscarry'
];
// Extraction runs in three phases, and the split matters:
//
// read - walk the LIVE dom, measure and compute everything that only exists
@ -770,167 +699,6 @@ function applyReadState(cloneRoot, state) {
normalizeMathMl(cloneRoot);
}
// MathML carries its meaning in its attributes at least as much as in its tags.
// display="block" alone is the difference between a centred display equation and
// something jammed into the middle of a sentence; stretchy and largeop size the
// brackets and the integral signs; columnalign is what makes a matrix line up;
// linethickness="0" is how a binomial coefficient is written. Only alttext used
// to survive, so all of that was lost on every formula.
//
// The vocabulary is allowed wholesale rather than enumerated, which is the
// opposite of how the HTML attributes above are handled and deliberately so:
// MathML's is a closed vocabulary defined by a spec, an attribute this extension
// has not heard of is far more likely to be one MathML has than one an attacker
// invented, and a list of thirty names would go out of date silently. What is
// excluded is what is dangerous, or what would break the file:
//
// on* event handlers
// href, src MathML's own link and image attributes. href can hold a
// javascript: url, and src would point at a file that is not
// in the archive.
// style, class the chapter stylesheet is generated from computed styles,
// never copied from the page
// id, data-* an html id is carried over - it is what a link inside the
// page points at - but nothing ever links into a formula, and
// an id here would only be one more name the ids minted while
// the chapter is written have to avoid colliding with
// anything with a colon, or that is not a plain lowercase name. An xmlns: or
// xlink: attribute needs a namespace declaration the chapter documents do not
// make, and an undeclared prefix does not make one formula wrong, it makes the
// whole xhtml file unparseable.
var deniedMathMLAttributes = ['href', 'src', 'style', 'class', 'id', 'xmlns'];
function isAllowedMathMLAttribute(name) {
return /^[a-z][a-z0-9-]*$/.test(name) &&
name.indexOf('on') !== 0 &&
name.indexOf('data-') !== 0 &&
deniedMathMLAttributes.indexOf(name) < 0;
}
// HTML attributes are enumerated, where MathML's are allowed wholesale. The
// vocabularies differ in the way that matters: HTML's is huge, mostly about
// behaviour rather than meaning, and full of names that either do nothing in a
// reader (onclick, target, srcset) or actively break the file (style and class,
// which the generated stylesheet owns).
//
// What is here is the subset that changes what the chapter says. Everything on
// this list was previously dropped, and dropping it was not a loss of polish:
//
// colspan, rowspan a merged cell renders as an unmerged one, so every row
// after it is short and the columns stop lining up. This
// is a wrong table, not a less navigable one.
// span same, for a column group
// start, reversed a list that should read 7, 8, 9 renders 1, 2, 3
// value same, for one item in it
// scope the only cheap way to say which cells a header heads
// lang a chapter is tagged with one language; a quotation in
// another is what a nested lang is for, and a speech
// synthesiser reads it in the wrong voice without it
// dir bdo is on the allowed tag list and does nothing at all
// without dir, so an RTL run inside an LTR paragraph came
// out reversed
// datetime, value what <time> and <data> are for - without them both tags
// are a span
// cite, title the source of a quotation, the expansion of an
// abbreviation
//
// Deliberately still absent: the ARIA attributes and headers. role has to be
// valid against the vocabulary epubcheck knows or the file fails to validate,
// and a page's roles are written for a browser, not a reading system; headers
// and aria-labelledby name other ids, and an id whose element extraction
// dropped turns a helpful attribute into an unresolved reference, which is an
// error rather than a missing nicety.
//
// A value that does not check out is dropped rather than corrected. The chapter
// is XHTML read by a validator: colspan="two" is not a small blemish there.
function attrEnum(allowed) {
return function(value) {
var normalized = value.trim().toLowerCase();
return allowed.indexOf(normalized) > -1 ? normalized : '';
};
}
// Bounded on purpose: colspan="100000" is a page bug or an attack on the
// reader's layout engine, never a table.
function attrCount(min) {
return function(value) {
var normalized = value.trim();
if (!/^[0-9]{1,4}$/.test(normalized)) {
return '';
}
var number = parseInt(normalized, 10);
return number >= min ? String(number) : '';
};
}
// <ol start> and <li value> are the two that may be negative or zero
function attrInteger(value) {
var normalized = value.trim();
return /^-?[0-9]{1,9}$/.test(normalized) ? String(parseInt(normalized, 10)) : '';
}
function attrText(value) {
return value.replace(/\s+/g, ' ').trim();
}
// datetime is a format, not free text, and epubcheck validates it: letting a
// page's junk through would turn a tag that carries a date into an error in the
// file. The subset here - a year, a month, a date, any of them with a time and
// an offset, or a bare time - is what pages actually write. The exotic
// remainder of the html grammar (weeks, durations, yearless dates) is dropped
// rather than guessed at, which costs the attribute and keeps the element.
var DATETIME_REGEX = new RegExp(
'^(' +
'[0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?' +
'([T ][0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]{1,3})?)?' +
'(Z|[+-][0-9]{2}:?[0-9]{2})?)?' +
'|' +
'[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]{1,3})?)?' +
')$');
function attrDateTime(value) {
var normalized = value.trim();
return DATETIME_REGEX.test(normalized) ? normalized : '';
}
// A url attribute that is not the one being followed for content - <blockquote
// cite> and <del cite> point at a source, and nothing in the archive depends on
// them resolving, but they are still urls a reader may open.
function attrUrl(value) {
var normalized = value.trim();
if (normalized === '' || !isSafeLinkUrl(normalized)) {
return '';
}
return getHref(normalized);
}
// Valid on every element, so they are checked before the per-tag table
var globalHtmlAttributes = {
lang: normalizeLanguageTag,
dir: attrEnum(['ltr', 'rtl', 'auto'])
};
var htmlAttributes = {
a: {},
abbr: {title: attrText},
blockquote: {cite: attrUrl},
q: {cite: attrUrl},
del: {cite: attrUrl, datetime: attrDateTime},
ins: {cite: attrUrl, datetime: attrDateTime},
time: {datetime: attrDateTime},
data: {value: attrText},
// A boolean attribute is true because it is there, whatever it says - and
// XHTML has no bare form, so it is written back out in the long one
ol: {start: attrInteger, reversed: function() { return 'reversed'; }},
li: {value: attrInteger},
col: {span: attrCount(1)},
colgroup: {span: attrCount(1)},
// rowspan="0" is legal and means "to the end of the section"; colspan="0" is
// not, which is why the two have different floors
td: {colspan: attrCount(1), rowspan: attrCount(0)},
th: {colspan: attrCount(1), rowspan: attrCount(0), scope: attrEnum(['row', 'col', 'rowgroup', 'colgroup'])}
};
// Ids already written into this chapter. An id is only addressable if it is
// unique in the file it lives in, and a page that repeats one - which is invalid
// html but common - would otherwise repeat it here too.
@ -940,56 +708,6 @@ var htmlAttributes = {
// not hand out an id the first already used.
var usedElementIds = new Set();
// walkHtmlFragment hands over values the HTML parser has already decoded, so
// they only need escaping - decoding here as well would turn a literal
// "&amp;lt;" on the page into a "<" in the epub
function attrValue(value) {
return escapeXMLChars(value == null ? '' : String(value));
}
// The id, the globals and whatever the tag itself allows. Attributes the caller
// has already written - src, alt, href, class and the img dimensions - are not
// in any of these tables and cannot be duplicated from here.
function extraHtmlAttributes(tag, attrs) {
let result = '';
let perTag = htmlAttributes[tag] || {};
// A page is free to write the same attribute twice. The parser hands both
// over, and writing both would make the chapter unparseable.
let written = new Set();
for (let i = 0; i < attrs.length; i++) {
let name = String(attrs[i].name).toLowerCase();
let rawValue = attrs[i].value == null ? '' : String(attrs[i].value);
if (written.has(name)) {
continue;
}
if (name === 'id') {
if (!isUsableId(rawValue) || usedElementIds.has(rawValue)) {
continue;
}
usedElementIds.add(rawValue);
written.add(name);
result += ' id="' + attrValue(rawValue) + '"';
continue;
}
let filter = globalHtmlAttributes[name] || perTag[name];
if (!filter) {
continue;
}
let value = filter(rawValue);
if (value === '') {
continue;
}
written.add(name);
result += ' ' + name + '="' + attrValue(value) + '"';
}
return result;
}
// Where an <a href> ends up. A link the page made to a position inside itself
// stays a link to a position inside the chapter, and everything else is
// resolved against the page so that it still works away from it.
@ -1014,6 +732,20 @@ function linkHref(rawHref) {
return getHref(value);
}
// Where a url-valued attribute that is not an <a href> ends up - <blockquote
// cite>, <del cite>. Nothing in the archive depends on these resolving and none
// of them addresses a position in the chapter, so unlike linkHref() a fragment
// is absolutised with everything else. What still matters is the scheme: a
// javascript: cite is inert in most readers, but not all, and epubcheck rejects
// it outright.
function citeUrl(rawValue) {
let value = rawValue == null ? '' : String(rawValue).trim();
if (value === '' || !isSafeLinkUrl(value)) {
return '';
}
return getHref(value);
}
// The fragment of an href that addresses this same document, or null if the href
// goes somewhere else. Both spellings count: the bare "#note-3", and the fully
// written out form a template engine emits, which resolves to this page.
@ -1040,184 +772,24 @@ function sameDocumentFragment(href) {
}
}
// Appends to allImages / extractedImages - the caller owns resetting them, so
// that a multi-range selection accumulates the images of every range instead of
// each range discarding the ones collected before it.
function parseHTML(rawContentString) {
let results = '';
let lastFragment = '';
// Tags written to the output that still need a closing tag. Kept here rather
// than trusting the input to be balanced: a page can close tags it never
// opened, or leave tags open, and the epub still has to be valid XML.
let openTags = [];
// Tags whose content is being dropped, innermost last
let skippedTags = [];
let isVoidTag = (tag) => voidTags.indexOf(tag) > -1;
let closeTagsDownTo = (index) => {
for (let i = openTags.length - 1; i >= index; i--) {
results += '</' + openTags[i] + '>';
}
openTags.length = index;
};
try {
walkHtmlFragment(rawContentString, {
start: function(tag, attrs, unary) {
if (skippedTags.length > 0) {
// Inside dropped content - track nesting so that the matching
// end tag, and only it, ends the skip
if (!unary && !isVoidTag(tag)) {
skippedTags.push(tag);
}
return;
}
if (allowedTags.indexOf(tag) < 0) {
if (strippedContentTags.indexOf(tag) > -1 && !unary && !isVoidTag(tag)) {
skippedTags.push(tag);
}
return;
}
let tmpAttrsTxt = '';
if (tag === 'img') {
let tmpSrc = ''
// null and '' are different answers: null is a source that
// never said anything about this image, '' is one that
// deliberately marked it decorative. Collapsing them - which
// is what writing alt="" on everything did - turns every
// undescribed image into a false claim that it needs no
// description, and the accessibility metadata is computed
// from exactly this distinction.
let tmpAlt = null;
for (let i = 0; i < attrs.length; i++) {
if (attrs[i].name === 'src') {
tmpSrc = getImageSrc(attrs[i].value)
tmpAttrsTxt += ' src="' + attrValue(tmpSrc) + '"';
} else if (attrs[i].name === 'alt') {
tmpAlt = attrs[i].value == null ? '' : String(attrs[i].value);
} else if (attrs[i].name === 'data-class') {
tmpAttrsTxt += ' class="' + attrValue(attrs[i].value) + '"';
} else if (attrs[i].name === 'width') {
// used when converting svg to img - the result image was too big
tmpAttrsTxt += ' width="' + attrValue(attrs[i].value) + '"';
} else if (attrs[i].name === 'height') {
// used when converting svg to img - the result image was too big
tmpAttrsTxt += ' height="' + attrValue(attrs[i].value) + '"';
}
}
if (tmpSrc === '') {
// ignore imgs without source
return;
}
if (tmpAlt !== null) {
tmpAttrsTxt += ' alt="' + attrValue(tmpAlt.replace(/\s+/g, ' ').trim()) + '"';
}
} else if (tag === 'a') {
for (let i = 0; i < attrs.length; i++) {
if (attrs[i].name === 'href') {
let href = linkHref(attrs[i].value);
if (href !== '') {
tmpAttrsTxt += ' href="' + attrValue(href) + '"';
}
} else if (attrs[i].name === 'data-class') {
tmpAttrsTxt += ' class="' + attrValue(attrs[i].value) + '"';
}
}
} else if (mathMLTags.indexOf(tag) > -1) {
if (tag === 'math') {
// in xhtml, MathML is only MathML because of this
tmpAttrsTxt += ' xmlns="' + MATHML_NS + '"';
}
for (let i = 0; i < attrs.length; i++) {
if (isAllowedMathMLAttribute(attrs[i].name)) {
tmpAttrsTxt += ' ' + attrs[i].name + '="' + attrValue(attrs[i].value) + '"';
}
}
} else {
for (let i = 0; i < attrs.length; i++) {
if (attrs[i].name === 'data-class') {
tmpAttrsTxt += ' class="' + attrValue(attrs[i].value) + '"';
}
}
}
// MathML has been through its own allowlist just above, and the
// two vocabularies do not share attributes - lang and dir mean
// nothing on an <mrow>, and an id there is denied on purpose.
if (mathMLTags.indexOf(tag) < 0) {
tmpAttrsTxt += extraHtmlAttributes(tag, attrs);
}
// A void tag, or a tag the page self-closed ("<span/>"), never gets
// an end callback - close it here or the output is unbalanced
if (isVoidTag(tag) || unary) {
lastFragment = '<' + tag + tmpAttrsTxt + '/>';
} else {
lastFragment = '<' + tag + tmpAttrsTxt + '>';
openTags.push(tag);
}
results += lastFragment;
lastFragment = '';
},
end: function(tag) {
if (skippedTags.length > 0) {
for (let i = skippedTags.length - 1; i >= 0; i--) {
if (skippedTags[i] === tag) {
skippedTags.length = i;
return;
}
}
// End tag for something never opened inside the dropped content
return;
}
if (allowedTags.indexOf(tag) < 0 || isVoidTag(tag)) {
return;
}
for (let i = openTags.length - 1; i >= 0; i--) {
if (openTags[i] === tag) {
// Anything still open inside it was never closed by the page
closeTagsDownTo(i);
return;
}
}
// End tag without a matching start tag - dropped
},
chars: function(text) {
if (skippedTags.length > 0) {
return;
}
// already decoded by the parser - see attrValue above
results += escapeXMLText(text);
},
comment: function(text) {
// results += "<!--" + text + "-->";
}
});
// Whatever the page left open
closeTagsDownTo(0);
return results;
} catch (e) {
console.log('Error:', e);
// Keep the part that was parsed before the error rather than losing the
// whole page, but close it first so the epub stays valid
try {
closeTagsDownTo(0);
} catch (e2) {
console.log('Error:', e2);
}
return results;
}
// How the shared sanitizer resolves what only a live page can answer: where a
// url points, and which images are being downloaded. What these read and append
// to - imageFileNames, allImages, usedElementIds - is job-wide rather than
// per fragment, which is what lets a selection spanning several ranges come out
// as one chapter with one set of images and no id handed out twice.
//
// The editor answers the same questions without a page - see sanitizeOptions()
// in sanitizeHtml.js, whose defaults are exactly that.
function extractionSanitizeOptions() {
return sanitizeOptions({
resolveImageSrc: getImageSrc,
resolveLinkHref: linkHref,
resolveUrl: citeUrl,
// the class attribute on a clone is still the page's own; the generated
// name is what applyReadState() wrote into data-class
classAttribute: 'data-class',
usedIds: usedElementIds
});
}
// htmlContent is already detached: either a clone of the body or the contents of
@ -1232,7 +804,7 @@ function getContent(htmlContent, state) {
// where a stray <td> or <tr> - what a selection inside a table produces -
// is dropped. Parsed on its own it is parsed "in template", which keeps
// table cells intact.
return '<div>' + parseHTML(tmp.innerHTML) + '</div>';
return '<div>' + parseHTML(tmp.innerHTML, extractionSanitizeOptions()) + '</div>';
} catch (e) {
console.log('Error:', e);
// Never hand back the node itself - it ends up spliced into the xhtml page

View file

@ -0,0 +1,572 @@
// The sanitizer and serializer, shared by everything that produces chapter
// content. It used to live inside extractHtml.js and could therefore only run as
// a content script, which made a page of our own - the chapter editor - unable
// to write content the build stage would accept.
//
// The contract: any string that comes out of parseHTML() satisfies the
// invariants the build stage relies on, whatever went in.
//
// - well formed XML: every tag is closed, void tags are self-closed, and tags
// the input left open or closed twice are balanced here
// - every attribute value is double quoted and XML-escaped, which is what lets
// saveEbook.js read the content back with regular expressions (HEADING_REGEX,
// ID_ATTR_REGEX, HREF_ATTR_REGEX) instead of parsing it again
// - only allowlisted tags and attributes survive, so nothing scripts, loads a
// remote resource, or names a vocabulary the package document never declares
// - text carries no entity but the five XML predefines, and no character XML
// 1.0 forbids
// - every id is an XML name and unique within the chapter
//
// It is also idempotent: running its own output through it again returns the
// same string. That is what makes an edit round trip - the editor serializes a
// DOM built from content this produced, and hands it straight back.
//
// Requires utils.js.
var MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
// MathML Core is what every WebKit and Chromium reading system implements -
// Apple Books, Thorium, Kobo, anything built on either engine - and it dropped a
// large part of what MathML 3 allowed. Everything Core removed is deliberately
// missing from this list and rewritten by normalizeMathMl() instead, because
// passing one of them through means a reader draws nothing where the element
// was:
//
// mfenced expanded into the mrow of <mo> fences the MathML 3 spec defines
// it to be equivalent to. A Core reader lays out an mfenced's
// children with no brackets at all, so "f(x)" comes out as "fx" -
// and older MathJax output is full of it.
// mlabeledtr renamed to <mtr>, keeping the label as its first cell.
// Unwrapping it would put <mtd>s straight into the <mtable>.
// menclose unwrapped: the box or strikethrough it draws is lost either
// way, and its content is the part worth keeping.
// mstack, msgroup, mlongdiv
// unwrapped. These lay digits out in a grid for long division;
// without the layout the digits are all that is left, but they
// are at least still in reading order.
// mscarries, mscarry
// dropped with their content - a carried digit annotates a
// position in that grid, and outside it it is a stray number in
// the middle of another number.
//
// mprescripts and none are the other direction: both are required by
// mmultiscripts and neither was ever allowed, so a prescripted symbol lost the
// marker saying where its prescripts begin.
var mathMLTags = [
'math', 'maction', 'merror', 'mfrac', 'mglyph', 'mi', 'mmultiscripts', 'mn', 'mo',
'mover', 'mpadded', 'mphantom', 'mprescripts', 'mroot', 'mrow', 'ms', 'mspace',
'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr',
'munder', 'munderover', 'none', 'semantics'
];
// blockquote was missing rather than excluded, and it is not a tag whose absence
// shows up as a gap: it is not in strippedContentTags either, so a quotation was
// unwrapped - the tag went, the words stayed - and ran into the paragraph before
// it with nothing to say where the quote started or ended.
var allowedTags = [
'address', 'article', 'aside', 'blockquote', 'footer', 'header', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'hgroup', 'nav', 'section', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure', 'hr', 'li',
'main', 'ol', 'p', 'pre', 'ul', 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data',
'dfn', 'em', 'i', 'img', 'kbd', 'mark', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'small', 'span',
'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr', 'del', 'ins', 'caption', 'col', 'colgroup',
'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'
// TODO ?
// ,'form', 'button'
// TODO svg support ?
// , 'svg', 'g', 'path', 'line', 'circle', 'text'
].concat(mathMLTags);
// const svgTags = ['svg', 'g', 'path', 'line', 'circle', 'text']
// Allowed tags that have no content and must be written self-closed, otherwise
// the generated page is not well formed XML
var voidTags = ['br', 'col', 'hr', 'img', 'wbr'];
// Tags that are dropped together with everything inside them. Any other tag
// that is not in allowedTags is unwrapped instead - the tag goes, its text stays.
var strippedContentTags = [
'script', 'style', 'noscript', 'template', 'iframe', 'object', 'embed', 'applet',
'head', 'title', 'meta', 'link', 'base',
'textarea', 'select', 'option', 'optgroup', 'input',
'audio', 'video', 'canvas', 'svg', 'map', 'area',
// <semantics> keeps its presentation MathML and loses its annotations. An
// annotation is an alternative encoding of the same formula - almost always
// the TeX it was typeset from - and a conforming reader renders only the
// first child of <semantics>, so it is never meant to be seen. But
// <annotation> was in neither list, so it was unwrapped instead and its raw
// TeX printed as visible text right beside the rendered formula. That is the
// single most visible MathML bug on a KaTeX page.
//
// The TeX itself is not thrown away: normalizeMathMl() lifts it into the
// <math alttext> first, where it is a fallback rather than a duplicate.
'annotation', 'annotation-xml',
'mscarries', 'mscarry'
];
// ---- attributes -------------------------------------------------------------
//
// MathML carries its meaning in its attributes at least as much as in its tags.
// display="block" alone is the difference between a centred display equation and
// something jammed into the middle of a sentence; stretchy and largeop size the
// brackets and the integral signs; columnalign is what makes a matrix line up;
// linethickness="0" is how a binomial coefficient is written. Only alttext used
// to survive, so all of that was lost on every formula.
//
// The vocabulary is allowed wholesale rather than enumerated, which is the
// opposite of how the HTML attributes below are handled and deliberately so:
// MathML's is a closed vocabulary defined by a spec, an attribute this extension
// has not heard of is far more likely to be one MathML has than one an attacker
// invented, and a list of thirty names would go out of date silently. What is
// excluded is what is dangerous, or what would break the file:
//
// on* event handlers
// href, src MathML's own link and image attributes. href can hold a
// javascript: url, and src would point at a file that is not
// in the archive.
// style, class the chapter stylesheet is generated from computed styles,
// never copied from the page
// id, data-* an html id is carried over - it is what a link inside the
// page points at - but nothing ever links into a formula, and
// an id here would only be one more name the ids minted while
// the chapter is written have to avoid colliding with
// anything with a colon, or that is not a plain lowercase name. An xmlns: or
// xlink: attribute needs a namespace declaration the chapter documents do not
// make, and an undeclared prefix does not make one formula wrong, it makes the
// whole xhtml file unparseable.
var deniedMathMLAttributes = ['href', 'src', 'style', 'class', 'id', 'xmlns'];
function isAllowedMathMLAttribute(name) {
return /^[a-z][a-z0-9-]*$/.test(name) &&
name.indexOf('on') !== 0 &&
name.indexOf('data-') !== 0 &&
deniedMathMLAttributes.indexOf(name) < 0;
}
// HTML attributes are enumerated, where MathML's are allowed wholesale. The
// vocabularies differ in the way that matters: HTML's is huge, mostly about
// behaviour rather than meaning, and full of names that either do nothing in a
// reader (onclick, target, srcset) or actively break the file (style and class,
// which the generated stylesheet owns).
//
// What is here is the subset that changes what the chapter says. Everything on
// this list was previously dropped, and dropping it was not a loss of polish:
//
// colspan, rowspan a merged cell renders as an unmerged one, so every row
// after it is short and the columns stop lining up. This
// is a wrong table, not a less navigable one.
// span same, for a column group
// start, reversed a list that should read 7, 8, 9 renders 1, 2, 3
// value same, for one item in it
// scope the only cheap way to say which cells a header heads
// lang a chapter is tagged with one language; a quotation in
// another is what a nested lang is for, and a speech
// synthesiser reads it in the wrong voice without it
// dir bdo is on the allowed tag list and does nothing at all
// without dir, so an RTL run inside an LTR paragraph came
// out reversed
// datetime, value what <time> and <data> are for - without them both tags
// are a span
// cite, title the source of a quotation, the expansion of an
// abbreviation
//
// Deliberately still absent: the ARIA attributes and headers. role has to be
// valid against the vocabulary epubcheck knows or the file fails to validate,
// and a page's roles are written for a browser, not a reading system; headers
// and aria-labelledby name other ids, and an id whose element extraction
// dropped turns a helpful attribute into an unresolved reference, which is an
// error rather than a missing nicety.
//
// A value that does not check out is dropped rather than corrected. The chapter
// is XHTML read by a validator: colspan="two" is not a small blemish there.
function attrEnum(allowed) {
return function(value) {
var normalized = value.trim().toLowerCase();
return allowed.indexOf(normalized) > -1 ? normalized : '';
};
}
// Bounded on purpose: colspan="100000" is a page bug or an attack on the
// reader's layout engine, never a table.
function attrCount(min) {
return function(value) {
var normalized = value.trim();
if (!/^[0-9]{1,4}$/.test(normalized)) {
return '';
}
var number = parseInt(normalized, 10);
return number >= min ? String(number) : '';
};
}
// <ol start> and <li value> are the two that may be negative or zero
function attrInteger(value) {
var normalized = value.trim();
return /^-?[0-9]{1,9}$/.test(normalized) ? String(parseInt(normalized, 10)) : '';
}
function attrText(value) {
return value.replace(/\s+/g, ' ').trim();
}
// datetime is a format, not free text, and epubcheck validates it: letting a
// page's junk through would turn a tag that carries a date into an error in the
// file. The subset here - a year, a month, a date, any of them with a time and
// an offset, or a bare time - is what pages actually write. The exotic
// remainder of the html grammar (weeks, durations, yearless dates) is dropped
// rather than guessed at, which costs the attribute and keeps the element.
var DATETIME_REGEX = new RegExp(
'^(' +
'[0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?' +
'([T ][0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]{1,3})?)?' +
'(Z|[+-][0-9]{2}:?[0-9]{2})?)?' +
'|' +
'[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]{1,3})?)?' +
')$');
function attrDateTime(value) {
var normalized = value.trim();
return DATETIME_REGEX.test(normalized) ? normalized : '';
}
// A url attribute that is not the one being followed for content - <blockquote
// cite> and <del cite> point at a source, and nothing in the archive depends on
// them resolving, but they are still urls a reader may open.
function attrUrl(value, options) {
return options.resolveUrl(value.trim());
}
// Valid on every element, so they are checked before the per-tag table
var globalHtmlAttributes = {
lang: normalizeLanguageTag,
dir: attrEnum(['ltr', 'rtl', 'auto'])
};
var htmlAttributes = {
a: {},
abbr: {title: attrText},
blockquote: {cite: attrUrl},
q: {cite: attrUrl},
del: {cite: attrUrl, datetime: attrDateTime},
ins: {cite: attrUrl, datetime: attrDateTime},
time: {datetime: attrDateTime},
data: {value: attrText},
// A boolean attribute is true because it is there, whatever it says - and
// XHTML has no bare form, so it is written back out in the long one
ol: {start: attrInteger, reversed: function() { return 'reversed'; }},
li: {value: attrInteger},
col: {span: attrCount(1)},
colgroup: {span: attrCount(1)},
// rowspan="0" is legal and means "to the end of the section"; colspan="0" is
// not, which is why the two have different floors
td: {colspan: attrCount(1), rowspan: attrCount(0)},
th: {colspan: attrCount(1), rowspan: attrCount(0), scope: attrEnum(['row', 'col', 'rowgroup', 'colgroup'])}
};
// walkHtmlFragment hands over values the HTML parser has already decoded, so
// they only need escaping - decoding here as well would turn a literal
// "&amp;lt;" on the page into a "<" in the epub
function attrValue(value) {
return escapeXMLChars(value == null ? '' : String(value));
}
// The id, the globals and whatever the tag itself allows. Attributes the caller
// has already written - src, alt, href, class and the img dimensions - are not
// in any of these tables and cannot be duplicated from here.
function extraHtmlAttributes(tag, attrs, options) {
let result = '';
let perTag = htmlAttributes[tag] || {};
// A page is free to write the same attribute twice. The parser hands both
// over, and writing both would make the chapter unparseable.
let written = new Set();
for (let i = 0; i < attrs.length; i++) {
let name = String(attrs[i].name).toLowerCase();
let rawValue = attrs[i].value == null ? '' : String(attrs[i].value);
if (written.has(name)) {
continue;
}
if (name === 'id') {
if (!isUsableId(rawValue) || options.usedIds.has(rawValue)) {
continue;
}
options.usedIds.add(rawValue);
written.add(name);
result += ' id="' + attrValue(rawValue) + '"';
continue;
}
let filter = globalHtmlAttributes[name] || perTag[name];
if (!filter) {
continue;
}
let value = filter(rawValue, options);
if (value === '') {
continue;
}
written.add(name);
result += ' ' + name + '="' + attrValue(value) + '"';
}
return result;
}
// ---- where urls and images resolve ------------------------------------------
//
// The only part of sanitizing that depends on where the content came from.
// Extraction has a live page to resolve relative references against and images
// it is about to download; the editor page has neither - what it hands back is
// content this module already wrote once, edited in place. So the defaults below
// are the editor's: keep what is already resolved, drop what cannot be.
// An image reference that names a file in the archive, as getImageSrc() writes
// it. Anything else - a url the user pasted in, a leftover reference from a
// chapter buffered by an older version - would point at a resource the book does
// not contain, which fails the whole book rather than one picture.
var ARCHIVE_IMAGE_SRC_REGEX = /^(?:\.{1,2}\/)*images\/[^/?#]+$/i;
function keepArchiveImageSrc(value) {
let text = String(value == null ? '' : value).trim();
return ARCHIVE_IMAGE_SRC_REGEX.test(text) ? text : '';
}
// A url that is already absolute is kept as it stands. A relative one has
// nothing to resolve against here - the editor page's own address is certainly
// not it - so it loses its href and keeps its text.
function keepAbsoluteUrl(value) {
let text = String(value == null ? '' : value).trim();
if (text === '' || !isSafeLinkUrl(text)) {
return '';
}
try {
return new URL(text).href;
} catch (e) {
return '';
}
}
// ...and a link into the chapter itself stays one. resolveInternalLinks() drops
// the ones whose target did not survive, at build time, when the ids are final.
function keepKnownHref(value) {
let text = String(value == null ? '' : value).trim();
if (text.indexOf('#') === 0) {
return isUsableId(text.substring(1)) ? text : '';
}
return keepAbsoluteUrl(text);
}
function sanitizeOptions(overrides) {
let options = {
// an <img src> as the page wrote it -> the src to write, or '' to drop
// the image along with its tag
resolveImageSrc: keepArchiveImageSrc,
// an <a href> -> the href to write, or '' to keep the text and drop the
// link
resolveLinkHref: keepKnownHref,
// every other url-valued attribute - see attrUrl
resolveUrl: keepAbsoluteUrl,
// Which attribute carries the generated css class name. Extraction marks
// the clone with data-class, because a class attribute at that point is
// still the page's own and must not survive; by the time the content is
// written the generated name is what the class attribute holds, so
// re-sanitizing reads it from there.
classAttribute: 'class',
// Ids already written into this chapter, so that a page repeating one -
// invalid html, but common - does not repeat it here. Shared across
// calls by a caller producing one chapter from several fragments.
usedIds: new Set()
};
if (overrides) {
Object.keys(overrides).forEach(function(key) {
if (overrides[key] !== undefined && overrides[key] !== null) {
options[key] = overrides[key];
}
});
}
return options;
}
// ---- the sanitizer ----------------------------------------------------------
function parseHTML(rawContentString, options) {
options = options || sanitizeOptions();
let results = '';
let lastFragment = '';
// Tags written to the output that still need a closing tag. Kept here rather
// than trusting the input to be balanced: a page can close tags it never
// opened, or leave tags open, and the epub still has to be valid XML.
let openTags = [];
// Tags whose content is being dropped, innermost last
let skippedTags = [];
let isVoidTag = (tag) => voidTags.indexOf(tag) > -1;
let closeTagsDownTo = (index) => {
for (let i = openTags.length - 1; i >= index; i--) {
results += '</' + openTags[i] + '>';
}
openTags.length = index;
};
try {
walkHtmlFragment(rawContentString, {
start: function(tag, attrs, unary) {
if (skippedTags.length > 0) {
// Inside dropped content - track nesting so that the matching
// end tag, and only it, ends the skip
if (!unary && !isVoidTag(tag)) {
skippedTags.push(tag);
}
return;
}
if (allowedTags.indexOf(tag) < 0) {
if (strippedContentTags.indexOf(tag) > -1 && !unary && !isVoidTag(tag)) {
skippedTags.push(tag);
}
return;
}
let tmpAttrsTxt = '';
if (tag === 'img') {
let tmpSrc = ''
// null and '' are different answers: null is a source that
// never said anything about this image, '' is one that
// deliberately marked it decorative. Collapsing them - which
// is what writing alt="" on everything did - turns every
// undescribed image into a false claim that it needs no
// description, and the accessibility metadata is computed
// from exactly this distinction.
let tmpAlt = null;
for (let i = 0; i < attrs.length; i++) {
if (attrs[i].name === 'src') {
tmpSrc = options.resolveImageSrc(attrs[i].value)
tmpAttrsTxt += ' src="' + attrValue(tmpSrc) + '"';
} else if (attrs[i].name === 'alt') {
tmpAlt = attrs[i].value == null ? '' : String(attrs[i].value);
} else if (attrs[i].name === options.classAttribute) {
tmpAttrsTxt += ' class="' + attrValue(attrs[i].value) + '"';
} else if (attrs[i].name === 'width') {
// used when converting svg to img - the result image was too big
tmpAttrsTxt += ' width="' + attrValue(attrs[i].value) + '"';
} else if (attrs[i].name === 'height') {
// used when converting svg to img - the result image was too big
tmpAttrsTxt += ' height="' + attrValue(attrs[i].value) + '"';
}
}
if (tmpSrc === '') {
// ignore imgs without source
return;
}
if (tmpAlt !== null) {
tmpAttrsTxt += ' alt="' + attrValue(tmpAlt.replace(/\s+/g, ' ').trim()) + '"';
}
} else if (tag === 'a') {
for (let i = 0; i < attrs.length; i++) {
if (attrs[i].name === 'href') {
let href = options.resolveLinkHref(attrs[i].value);
if (href !== '') {
tmpAttrsTxt += ' href="' + attrValue(href) + '"';
}
} else if (attrs[i].name === options.classAttribute) {
tmpAttrsTxt += ' class="' + attrValue(attrs[i].value) + '"';
}
}
} else if (mathMLTags.indexOf(tag) > -1) {
if (tag === 'math') {
// in xhtml, MathML is only MathML because of this
tmpAttrsTxt += ' xmlns="' + MATHML_NS + '"';
}
for (let i = 0; i < attrs.length; i++) {
if (isAllowedMathMLAttribute(attrs[i].name)) {
tmpAttrsTxt += ' ' + attrs[i].name + '="' + attrValue(attrs[i].value) + '"';
}
}
} else {
for (let i = 0; i < attrs.length; i++) {
if (attrs[i].name === options.classAttribute) {
tmpAttrsTxt += ' class="' + attrValue(attrs[i].value) + '"';
}
}
}
// MathML has been through its own allowlist just above, and the
// two vocabularies do not share attributes - lang and dir mean
// nothing on an <mrow>, and an id there is denied on purpose.
if (mathMLTags.indexOf(tag) < 0) {
tmpAttrsTxt += extraHtmlAttributes(tag, attrs, options);
}
// A void tag, or a tag the page self-closed ("<span/>"), never gets
// an end callback - close it here or the output is unbalanced
if (isVoidTag(tag) || unary) {
lastFragment = '<' + tag + tmpAttrsTxt + '/>';
} else {
lastFragment = '<' + tag + tmpAttrsTxt + '>';
openTags.push(tag);
}
results += lastFragment;
lastFragment = '';
},
end: function(tag) {
if (skippedTags.length > 0) {
for (let i = skippedTags.length - 1; i >= 0; i--) {
if (skippedTags[i] === tag) {
skippedTags.length = i;
return;
}
}
// End tag for something never opened inside the dropped content
return;
}
if (allowedTags.indexOf(tag) < 0 || isVoidTag(tag)) {
return;
}
for (let i = openTags.length - 1; i >= 0; i--) {
if (openTags[i] === tag) {
// Anything still open inside it was never closed by the page
closeTagsDownTo(i);
return;
}
}
// End tag without a matching start tag - dropped
},
chars: function(text) {
if (skippedTags.length > 0) {
return;
}
// already decoded by the parser - see attrValue above
results += escapeXMLText(text);
},
comment: function(text) {
// results += "<!--" + text + "-->";
}
});
// Whatever the page left open
closeTagsDownTo(0);
return results;
} catch (e) {
console.log('Error:', e);
// Keep the part that was parsed before the error rather than losing the
// whole page, but close it first so the epub stays valid
try {
closeTagsDownTo(0);
} catch (e2) {
console.log('Error:', e2);
}
return results;
}
}
// The entry point for a caller holding a chapter that has already been through
// extraction - the editor, after the user has changed something. The result is
// safe to store as page.content and to hand to the build stage.
function sanitizeChapterContent(content) {
return parseHTML(String(content == null ? '' : content), sanitizeOptions());
}

View file

@ -1,8 +1,83 @@
var cssFileName = 'ebook.css';
var ebookTitle = null;
// Set by buildEbookFromChapters() from storage. Left null on the one-shot
// "save this page" path, which has no stored identity - see getBookId().
var ebookUuid = null;
// ---- the stylesheets the archive carries -------------------------------------
//
// Two kinds of css reach a book, and only one of them is filtered by content.
// What extraction captures is scraped off somebody else's page, and supportedCss
// in extractHtml.js decides which of its declarations are worth carrying. What a
// user writes in the chapter editor is not that: it is theirs, it is meant to
// change how the book looks, and holding it to an allowlist would only mean most
// of what they typed silently did nothing. Nothing they can write in it makes the
// package invalid either - css a reading system does not understand is css it
// ignores.
//
// What this removes is neither of those judgements. It is the two ways a
// stylesheet reaches outside the archive, which both kinds can do:
//
// @import pulls in another stylesheet, which for a remote address is a
// network fetch from inside a file that is supposed to read
// offline, and for a local one names a file the archive has no
// way to contain.
// remote url() a background image or a font fetched off the network. EPUB
// requires a manifest item referencing a remote resource to say
// so (properties="remote-resources"), and this build makes no
// such claim about any file it writes - so rather than have the
// claim and the content disagree, the reference goes. Captured
// css hits this as well: 'list-style' serializes an absolute
// url whenever the page gave its bullets a picture.
//
// A url() that is not remote is left alone: "../images/photo.jpg" from a chapter
// stylesheet resolves to a picture the book really does contain, and using one as
// a background is a reasonable thing to want. data: urls stay for the same
// reason - they are carried in the file rather than fetched.
// A url() target that leaves the archive: anything with a scheme, plus the
// scheme-relative "//host/path" form. data: is not one of these - it is the
// resource itself, not an address to go and get it from.
function isRemoteCssUrl(target) {
var value = String(target || '').trim();
if (/^data:/i.test(value)) {
return false;
}
return /^(?:[a-z][a-z0-9+.\-]*:|\/\/)/i.test(value);
}
// An @import rule runs to the first semicolon; a quoted string inside it may
// contain one, so strings are matched rather than scanned through. A rule left
// unterminated at the end of the file, or at the end of the block it sits in,
// ends there.
var CSS_IMPORT_REGEX = /@import\b(?:[^;{}'"]|'[^']*'|"[^"]*")*(?:;|(?=\})|$)/gi;
var CSS_URL_REGEX = /\burl\(\s*(?:"([^"]*)"|'([^']*)'|([^)"'\s]*))\s*\)/gi;
// What a removed url() becomes. Dropping the token outright would leave
// "background-image: ;", which a parser discards along with any fallback beside
// it; 'none' is a value the properties that take a url actually accept, and
// where it is not one the declaration is dropped by css error recovery, which is
// the same outcome.
var REMOVED_CSS_URL = 'none';
function sanitizeStylesheet(css) {
return String(css == null ? '' : css)
.replace(CSS_IMPORT_REGEX, '')
.replace(CSS_URL_REGEX, function(rule, quoted, single, bare) {
var target = quoted !== undefined ? quoted :
single !== undefined ? single : bare;
return isRemoteCssUrl(target) ? REMOVED_CSS_URL : rule;
});
}
// The stylesheet a chapter is written with: what extraction captured from the
// page, then what the user added in the editor. In that order, so that a rule
// the user writes wins over the scraped one it collides with - which is the
// whole point of being able to write one.
function chapterStyleContent(page) {
var captured = typeof page.styleFileContent === 'string' ? page.styleFileContent : '';
var authored = typeof page.customCss === 'string' ? page.customCss : '';
if (authored.trim() === '') {
return sanitizeStylesheet(captured);
}
return sanitizeStylesheet(captured === '' ? authored : captured + '\n' + authored);
}
// Compression is set per file, never globally: OCF requires 'mimetype' to be
// the first entry and STORE-d, so readers can sniff the magic bytes.
@ -63,7 +138,12 @@ function normalizeChapters(allPages) {
images: Array.isArray(page.images) ? page.images : [],
styleFileName: typeof page.styleFileName === 'string' && page.styleFileName ?
page.styleFileName : 'style' + index + '.css',
styleFileContent: typeof page.styleFileContent === 'string' ? page.styleFileContent : ''
styleFileContent: typeof page.styleFileContent === 'string' ? page.styleFileContent : '',
// kept beside the captured stylesheet rather than merged into it, so
// that reopening the editor shows the user what they wrote and not
// the page's computed styles with their rules buried at the bottom.
// chapterStyleContent() is where the two become one file.
customCss: typeof page.customCss === 'string' ? page.customCss : ''
}));
}).map(dropMissingImages);
}
@ -156,8 +236,8 @@ function getImagesIndex(allImages) {
// so that rebuilding after an edit updates the same book in a library instead of
// adding a second copy. A single page saved straight to disk was never stored
// and is a new book every time, so it gets a fresh id.
function getBookId() {
return 'urn:uuid:' + (ebookUuid || generateUuid());
function getBookId(uuid) {
return 'urn:uuid:' + (uuid || generateUuid());
}
// dc:source, one per chapter. sourceUrl was added after baseUrl already existed;
@ -812,29 +892,39 @@ function getExternalLinksIndex() { // TODO
}, '');
}
function buildEbookFromChapters() {
// Reads the whole book out of storage and builds it. For a caller that has no
// chapters in hand - everything else passes its own array to buildEbook(), which
// is what keeps a build from racing the storage write that preceded it.
function buildEbookFromStorage() {
getEbookTitle(function (title) {
ebookTitle = title;
if (!ebookTitle || ebookTitle.trim().length === 0) {
ebookTitle = 'eBook';
}
getEbookUuid(function (uuid) {
ebookUuid = uuid;
getEbookPages(_buildEbook);
getBookCss(function (css) {
getEbookPages(function (allPages) {
buildEbook(allPages, {title: title, uuid: uuid, css: css});
});
})
})
})
}
// FIXME remove - keep one function
function buildEbook(allPages, fromMenu=false) {
// the one-shot "save this page" path: nothing about it was stored, so it is
// a new book every time and must not inherit an id from a chapter build
ebookUuid = null;
_buildEbook(allPages, fromMenu);
}
// The one way an ebook is built - the one-shot "save this page" and the editor's
// "generate" differ only in what they pass here. Everything that is not
// derivable from the chapters themselves arrives in bookMeta:
//
// title what the book is called. Absent, the first chapter names it, which is
// the right answer for a single saved page.
// uuid dc:identifier. Absent, a fresh one is minted: the one-shot path
// stored nothing, so it is a new book every time and must not inherit
// an id from a chapter build - see getBookId().
// css the book-wide stylesheet, written by the user in the chapter editor.
// Absent, ebook.css is written empty, which is what it was for every
// book built before there was anywhere to type one.
//
// http://ebooks.stackexchange.com/questions/1183/what-is-the-minimum-required-content-for-a-valid-epub
function _buildEbook(allPages, fromMenu=false) {
function buildEbook(allPages, bookMeta) {
bookMeta = bookMeta || {};
var ebookTitle = typeof bookMeta.title === 'string' && bookMeta.title.trim().length > 0 ?
bookMeta.title : null;
allPages = normalizeChapters(allPages);
if (allPages.length === 0) {
finishJob('There are no valid chapters to save.');
@ -845,6 +935,7 @@ function _buildEbook(allPages, fromMenu=false) {
console.log('Prepare Content...');
var ebookFileName = 'eBook.epub';
var ebookName = '';
if (ebookTitle) {
// ~TODO a pre-processing function to apply escapeXMLChars to all page.titles
@ -858,7 +949,7 @@ function _buildEbook(allPages, fromMenu=false) {
// One instant for the whole package: dc:date (when this file was made) and
// dcterms:modified disagreeing by a second reads as two separate events.
var buildDate = new Date().toISOString().replace(/\.[0-9]+Z/i, 'Z');
var bookId = getBookId();
var bookId = getBookId(bookMeta.uuid);
var bookLanguage = getBookLanguage(allPages);
var bookDirection = getBookDirection(allPages);
var publishers = collectDistinct(allPages, function(m) { return m.publisher; });
@ -965,10 +1056,13 @@ function _buildEbook(allPages, fromMenu=false) {
DEFLATED
);
oebps.file(cssFileName, '', DEFLATED); //TODO
// The book-wide stylesheet: written by hand in the chapter editor, empty for
// every book whose author never opened that box. Every chapter links it, and
// so does the table of contents.
oebps.file(cssFileName, sanitizeStylesheet(bookMeta.css), DEFLATED);
var styleFolder = oebps.folder('style');
allPages.forEach(function(page) {
styleFolder.file(page.styleFileName, page.styleFileContent, DEFLATED);
styleFolder.file(page.styleFileName, chapterStyleContent(page), DEFLATED);
});
var pagesFolder = oebps.folder('pages');
@ -989,6 +1083,12 @@ function _buildEbook(allPages, fromMenu=false) {
dirAttribute(pageDirection) + '>' +
'<head>' +
'<title>' + tmpPageTitle+ '</title>' +
// In this order, and this is the cascade the preview reproduces: the
// book-wide stylesheet states what the book looks like, the chapter's
// own - the source page's computed styles, plus whatever the user
// added for this chapter alone - is the more specific answer and
// comes second.
'<link href="../' + cssFileName + '" rel="stylesheet" type="text/css" />' +
'<link href="../style/' + page.styleFileName + '" rel="stylesheet" type="text/css" />' +
'</head>' +
getChapterBody(page, outlines[index]) +

View file

@ -62,6 +62,27 @@ function saveEbookTitle(title) {
});
}
// The book-wide stylesheet, kept beside the title and the identifier because it
// belongs to the book being assembled rather than to any one chapter - and is
// discarded with them. Not to be confused with the per-site styles behind
// getStyles(): those are applied to a page while it is being captured and
// outlive any single book.
function getBookCss(callback) {
chrome.runtime.sendMessage({
type: "get book css"
}, function(response) {
callback(response && typeof response.css === 'string' ? response.css : '');
});
}
function saveBookCss(css) {
chrome.runtime.sendMessage({
type: "set book css",
css: css
}, function(response) {
});
}
// The identifier of the ebook being assembled from chapters. Minted once by the
// background and kept next to the chapters, because dc:identifier is what a
// library uses to decide whether a file is a new book or a newer copy of one it