Skip to main content

static/app.js

1/**
2 * FInd the path to `content.opf` inside a zip file.
3 *
4 * The `content.opf` file contains XML metadata about an ePub file.
5 * It's normally stored at the root of the ePub, but find it in case
6 * it's been stuffed inside a subdirectory.
7 *
8 * This returns the path, or `null` if there's no such file in
9 * the ePub.
10 */
11async function findContentOpfPath(zip) {
12 const parser = new DOMParser();
14 // First look for the mandatory file META-INF/container.xml,
15 // which contains a pointer to the `content.opf` file.
16 //
17 // The contents of this file will be XML of the form:
18 //
19 // <?xml version="1.0"?>
20 // <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
21 // <rootfiles>
22 // <rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
23 //
24 // </rootfiles>
25 // </container>
26 //
27 // In general AO3 stories put the `content.opf` at the top level,
28 // but let's go ahead and find it properly.
29 const containerXmlDoc = parser.parseFromString(
30 await zip.file('META-INF/container.xml').async('string'),
31 "text/xml"
32 );
34 const rootPath =
35 containerXmlDoc
36 .querySelector('rootfile')
37 .getAttribute('full-path');
39 console.debug(`Detected root path of EPUB file: ${rootPath}`);
41 return rootPath;
47/**
48 * Get key information about this fic from the unpacked EPUB file.
49 *
50 */
51async function getKeyFicInfo(zip) {
52 // Get the path to `content.opf`.
53 const rootPath = await findContentOpfPath(zip);
55 // Now go ahead and get the contents of `content.opf`.
56 //
57 // This is the rough strucutre of the contents:
58 //
59 // <?xml version='1.0' encoding='utf-8'?>
60 // <package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="uuid_id">
61 // <metadata xmlns:opf="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:calibre="http://calibre.kovidgoyal.net/2009/metadata">
62 // <dc:title>A Stab Wars Story</dc:title>
63 // <dc:creator opf:file-as="apricot" opf:role="aut">Apricot (Paradisi)</dc:creator>
64 // …
65 // </metadata>
66 // <manifest>
67 // <item id="html4" href="A_Stab_Wars_Story_split_000.xhtml" media-type="application/xhtml+xml"/>
68 // …
69 // </manifest>
70 // <spine toc="ncx">
71 // …
72 // </spine>
73 // </package>
74 //
75 // We want to get the title, creator, and href of that first HTML page.
76 const parser = new DOMParser();
77 const containerOpfXmlDoc = parser.parseFromString(
78 await zip.file(rootPath).async('string'),
79 'text/xml'
80 );
82 // The title of the fic will be in a <dc:title> node, for example:
83 //
84 // <dc:title>Operation Cameo</dc:title>
85 //
86 const namespaceResolver = (prefix) =>
87 prefix === 'dc' ? 'http://purl.org/dc/elements/1.1/' : null;
89 const title = containerOpfXmlDoc.evaluate(
90 './/dc:title',
91 containerOpfXmlDoc,
92 namespaceResolver,
93 XPathResult.STRING_TYPE
94 ).stringValue;
96 console.debug(`Detected title of EPUB file: ${title}`);
98 // The author of the fic will be in a <dc:author> node, for example:
99 //
100 // <dc:creator …>alexwlchan</dc:creator>
101 //
102 const author = containerOpfXmlDoc.evaluate(
103 './/dc:creator',
104 containerOpfXmlDoc,
105 namespaceResolver,
106 XPathResult.STRING_TYPE
107 ).stringValue;
109 console.debug(`Detected author of EPUB file: ${author}`);
111 // The href of the first HTML file will be the first <item> node
112 // with an HTML media type, for example:
113 //
114 // <item
115 // id="html4"
116 // href="A_Stab_Wars_Story_split_000.xhtml"
117 // media-type="application/xhtml+xml"/>
118 //
119 const firstHtmlPath =
120 containerOpfXmlDoc
121 .querySelector('item[media-type="application/xhtml+xml"]')
122 .getAttribute("href");
124 console.debug(`Detected first HTML file in EPUB file: ${firstHtmlPath}`);
126 // Now go ahead and read that file as HTML. This contains the metadata
127 // we actually want.
128 const firstHtmlDoc = parser.parseFromString(
129 await zip.file(firstHtmlPath).async('string'),
130 'text/html'
131 );
133 // Look for the contents of a <dl> which contains some metadata
134 // about this fic.
135 //
136 // We're interested in the <dd> after "Fandom:"
137 //
138 // <dl class="tags">
139 // <dt class="calibre3">Rating:</dt>
140 // <dd class="calibre4"><a href="http://archiveofourown.org/tags/General%20Audiences">General Audiences</a></dd>
141 // <dt class="calibre3">Archive Warning:</dt>
142 // <dd class="calibre4"><a href="http://archiveofourown.org/tags/No%20Archive%20Warnings%20Apply">No Archive Warnings Apply</a></dd>
143 // <dt class="calibre3">Category:</dt>
144 // <dd class="calibre4"><a href="http://archiveofourown.org/tags/Gen">Gen</a></dd>
145 // <dt class="calibre3">Fandom:</dt>
146 // <dd class="calibre4"><a href="http://archiveofourown.org/tags/Rogue%20One:%20A%20Star%20Wars%20Story%20(2016)">Rogue One: A Star Wars Story (2016)</a></dd>
147 //
148 const fandom =
149 Array.from(firstHtmlDoc.querySelectorAll('dt'))
150 .find(dt => dt.innerText === 'Fandom:' || dt.innerText === 'Fandoms:')
151 .nextSibling
152 .nextSibling
153 .innerText;
155 console.debug(`Detected fandom in first HTML file: ${fandom}`);
157 return { title: decodeXML(title), author, fandom };
162/**
163 * Decode the XML entities in a string.
164 */
165function decodeXML(input) {
166 if (/&amp;|&quot;|&#39;|'&lt;|&gt;/.test(input)) {
167 var doc = new DOMParser().parseFromString(input, "text/html");
168 return doc.documentElement.textContent;
169 }
170 return input;
175/**
176 * Choose a colour to represent the fics in this fandom.
177 *
178 * This is a random dark shade; the only important thing is that we
179 * can call it reproducibly to get the same colour if this is called
180 * the same time for the same fandom.
181 *
182 *
183 */
184function chooseColour(fandom) {
186 // Choose the first two words of the fandom title. This is enough
187 // to distinguish e.g. "Star Wars" and "Star Trek", but also means
188 // we'll get similar titles for fandoms with the same prefix.
189 const fandomSlice = fandom.split(" ").slice(0, 2).join(" ").replace(/:$/, '');
191 const seed = cyrb128(fandomSlice);
192 const getRand = sfc32(seed[0], seed[1], seed[2], seed[3]);
194 const hue = getRand();
196 // The saturation/lightness values are chosen to get a dark-ish
197 // shade that will look good with white text.
198 const saturation = 0.7 + 0.3 * getRand();
199 const lightness1 = 0.35;
200 const lightness2 = 0.2;
202 // Convert to rgb.
203 let [red1, green1, blue1] = hslToRgb(hue, saturation, lightness1);
204 let [red2, green2, blue2] = hslToRgb(hue, saturation, lightness2);
206 // Convert to a hex string.
207 return [
208 `#${numToHex(red1)}${numToHex(green1)}${numToHex(blue1)}`,
209 `#${numToHex(red2)}${numToHex(green2)}${numToHex(blue2)}`,
210 ];
215/**
216 * A seeded implementation of a random number generator in JavaScript.
217 *
218 * Written by Stack Overflow user bryc:
219 * https://stackoverflow.com/a/47593316/1558022
220 */
221function cyrb128(str) {
222 let h1 = 1779033703, h2 = 3144134277,
223 h3 = 1013904242, h4 = 2773480762;
224 for (let i = 0, k; i < str.length; i++) {
225 k = str.charCodeAt(i);
226 h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
227 h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
228 h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
229 h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
230 }
231 h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
232 h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
233 h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
234 h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
235 h1 ^= (h2 ^ h3 ^ h4), h2 ^= h1, h3 ^= h1, h4 ^= h1;
236 return [h1>>>0, h2>>>0, h3>>>0, h4>>>0];
239function sfc32(a, b, c, d) {
240 return function() {
241 a |= 0; b |= 0; c |= 0; d |= 0;
242 let t = (a + b | 0) + d | 0;
243 d = d + 1 | 0;
244 a = b ^ b >>> 9;
245 b = c + (c << 3) | 0;
246 c = (c << 21 | c >>> 11);
247 c = c + t | 0;
248 return (t >>> 0) / 4294967296;
249 }
255/**
256 * Converts an HSL color value to RGB. Conversion formula
257 * adapted from https://en.wikipedia.org/wiki/HSL_color_space.
258 * Assumes h, s, and l are contained in the set [0, 1] and
259 * returns r, g, and b in the set [0, 255].
260 *
261 * @param {number} h The hue
262 * @param {number} s The saturation
263 * @param {number} l The lightness
264 * @return {Array} The RGB representation
265 *
266 * This function is by Gary Tan and is taken from Stack Overflow:
267 * https://stackoverflow.com/a/9493060/1558022
268 */
269function hslToRgb(h, s, l) {
270 let r, g, b;
272 if (s === 0) {
273 r = g = b = l; // achromatic
274 } else {
275 const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
276 const p = 2 * l - q;
277 r = hueToRgb(p, q, h + 1/3);
278 g = hueToRgb(p, q, h);
279 b = hueToRgb(p, q, h - 1/3);
280 }
282 return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
285function hueToRgb(p, q, t) {
286 if (t < 0) t += 1;
287 if (t > 1) t -= 1;
288 if (t < 1/6) return p + (q - p) * 6 * t;
289 if (t < 1/2) return q;
290 if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
291 return p;
296/**
297 * Convert a number to a 2-digit hex string.
298 */
299function numToHex(n) {
300 const hex = n.toString(16);
301 return hex.length === 1 ? '0' + hex : hex;
306/**
307 * Create a PNG cover image for a book.
308 *
309 * Returns a <canvas> element for this cover.
310 */
311function createCoverImage(ficInfo) {
312 // I want something with a rough 2:3 ratio that's big enough
313 // for the text to look sharp, but we don't need anything huge.
314 const width = 600;
315 const height = 900;
317 const canvas = document.createElement("canvas");
318 canvas.setAttribute("width", 600);
319 canvas.setAttribute("height", 900);
321 const ctx = canvas.getContext("2d");
323 const gradient = ctx.createLinearGradient(0, 0, 0, height);
324 const colors = chooseColour(ficInfo.fandom);
325 gradient.addColorStop(0, colors[0]);
326 gradient.addColorStop(1, colors[1]);
327 ctx.fillStyle = gradient;
328 ctx.fillRect(0, 0, canvas.width, canvas.height);
330 // Set text style. We know white will look okay because we
331 // chose a dark background.
332 ctx.fillStyle = '#ffffffcc';
333 ctx.textAlign = 'center';
335 // Add the author name.
336 ctx.font = '62px Georgia';
338 const { lines: authorLines, separator } = getAuthorLines({
339 ctx, authorName: ficInfo.author, maxWidth: width * 0.75
340 });
342 drawLinesOfText({
343 ctx,
344 width,
345 lines: authorLines,
346 separator,
347 maxLines: 2,
348 lineStart: height * 0.82,
349 lineHeight: 77,
350 });
352 // Add the title.
353 //
354 // This may be split across multiple lines if it's long; we only
355 // show part of the title if it's long to keep the text readable.
356 //
357 // If there are too many lines, we truncate it and add ellipsis
358 // to indicate it's been truncated.
359 ctx.fillStyle = '#ffffff';
360 ctx.font = '88px Georgia';
362 const titleLines = getTitleLines({
363 ctx, title: ficInfo.title, maxWidth: width * 0.75,
364 });
366 drawLinesOfText({
367 ctx,
368 width,
369 lines: titleLines,
370 separator: " ",
371 maxLines: 5,
372 lineStart: height * 0.18,
373 lineHeight: 112,
374 });
376 return canvas;
381/**
382 * Given a list of words, work out how to fit them into lines on
383 * a <canvas> without exceeding the max width.
384 */
385function getLinesForWords({ ctx, words, maxWidth, separator }) {
386 var lines = [];
387 var currentLine = words[0];
389 // Go through the words one-by-one. If adding this word causes
390 // us to exceeed the width of the current line, create a new line
391 // and push the word down.
392 for (var i = 1; i < words.length; i++) {
393 var thisWord = words[i]
394 var candidateLine = currentLine + separator + thisWord;
396 var width = ctx.measureText(candidateLine).width;
398 if (width < maxWidth) {
399 currentLine = candidateLine;
400 } else {
401 lines.push(currentLine);
402 currentLine = thisWord;
403 }
404 }
406 // Remember to add a line for anything not already tracked.
407 lines.push(currentLine);
409 return { lines, separator };
414/**
415 * Split a title into lines to fit into a <canvas> without wrapping.
416 */
417function getTitleLines({ ctx, title, maxWidth }) {
418 const { lines } = getLinesForWords({
419 ctx, words: title.split(" "), maxWidth, separator: " "
420 });
422 return lines;
427/**
428 * Split an author name into lines to fit into a <canvas>.
429 *
430 * We try to apply some intelligence when we need to break across lines,
431 * e.g. breaking on spaces or uppercase characters.
432 *
433 * We only have room for two lines of text in the author name, so
434 * anything beyond that gets truncated.
435 */
436function getAuthorLines({ ctx, authorName, maxWidth }) {
438 // If the author name includes any spaces, assume we have a list
439 // of space separated words we can use.
440 if (authorName.includes(' ')) {
441 return getLinesForWords({
442 ctx, words: authorName.split(" "), maxWidth, separator: " "
443 });
444 }
446 // Another common convention is to use underscores, in which
447 // case we can split on that.
448 else if (authorName.includes('_')) {
449 return getLinesForWords({
450 ctx, words: authorName.split("_"), maxWidth, separator: "_"
451 });
452 }
454 // Another common convention is to use intercaps, e.g. JaneSmith,
455 // so we can split on those words if we need to.
456 else if (/[A-Z]/.test(authorName)) {
457 return getLinesForWords({
458 ctx,
459 words: authorName.replace(/([A-Z])/g, ' $1').trim().split(/\s+/),
460 maxWidth,
461 separator: ""
462 });
463 }
465 // Otherwise, we just break the string into individual characters
466 // and fit as many as we can onto each line.
467 else {
468 return getLinesForWords({
469 ctx,
470 words: [...authorName],
471 maxWidth,
472 separator: ""
473 });
474 }
480/**
481 * Add lines of text to a canvas.
482 *
483 * The text will be drawn in the middle of the page.
484 */
485function drawLinesOfText({ ctx, width, lines, separator, maxLines, lineStart, lineHeight }) {
487 // If there are more lines than we can fit, truncate to that length
488 // and add an ellipsis.
489 if (lines.length > maxLines) {
490 lines = lines.slice(0, maxLines);
491 lines[maxLines - 1] += '…';
492 }
494 // Got through and add the lines of text we're drawing. Depending
495 // on the separator, we may need to add a hyphen or similar to
496 // indicate line continuation.
497 for (lineno = 0; lineno < lines.length; lineno++) {
498 const thisLine = lines[lineno];
500 const displayLine =
501 separator === " " ? thisLine
502 : separator === "_" ? thisLine + "_"
503 : lineno < lines.length - 1 ? thisLine + "-"
504 : thisLine;
506 ctx.fillText(displayLine, width / 2, lineStart + lineno * lineHeight);
507 }
512/**
513 * Shuffle the elements of an array.
514 *
515 * This code is a Fisher–Yates (aka Knuth) Shuffle, taken from
516 * a Stack Overflow community wiki answer:
517 * https://stackoverflow.com/a/2450976/1558022
518 */
519function shuffle(array) {
520 let currentIndex = array.length;
522 // While there remain elements to shuffle...
523 while (currentIndex != 0) {
525 // Pick a remaining element...
526 let randomIndex = Math.floor(Math.random() * currentIndex);
527 currentIndex--;
529 // And swap it with the current element.
530 [array[currentIndex], array[randomIndex]] = [
531 array[randomIndex], array[currentIndex]];
532 }