mirror of
https://github.com/alexadam/save-as-ebook.git
synced 2026-10-19 07:35:04 +00:00
start working on chapter editor
This commit is contained in:
parent
c1a93e7591
commit
a16bf344e3
33 changed files with 3997 additions and 533 deletions
|
|
@ -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
48
tests/build-edited-epub.sh
Executable 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/</</g' -e 's/>/>/g' -e 's/&/\&/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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
469
tests/dom/chapter-editing.html
Normal file
469
tests/dom/chapter-editing.html
Normal 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 & 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
233
tests/dom/chapter-preview.html
Normal file
233
tests/dom/chapter-preview.html
Normal 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>
|
||||
|
|
@ -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
321
tests/dom/custom-css.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
163
tests/dom/sanitize-round-trip.html
Normal file
163
tests/dom/sanitize-round-trip.html
Normal 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 & an entity 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 & b c — d</p>');
|
||||
reportWellFormed('decoded entities are re-escaped as XML predefines', entities);
|
||||
report('no named entity survives',
|
||||
entities.indexOf(' ') < 0 && entities.indexOf('—') < 0 &&
|
||||
entities.indexOf('&amp;') < 0 && entities.indexOf('a & 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>
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": "Выбрать существующий стиль"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
623
web-extension/chapterContentEditor.js
Normal file
623
web-extension/chapterContentEditor.js
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
194
web-extension/chapterPreview.js
Normal file
194
web-extension/chapterPreview.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// "&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
|
||||
|
|
|
|||
572
web-extension/sanitizeHtml.js
Normal file
572
web-extension/sanitizeHtml.js
Normal 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
|
||||
// "&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());
|
||||
}
|
||||
|
|
@ -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]) +
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue