2 * FInd the path to `content.opf` inside a zip file.
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.
8 * This returns the path, or `null` if there's no such file in
11 async 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.
17 // The contents of this file will be XML of the form:
19 // <?xml version="1.0"?>
20 // <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
22 // <rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
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' ),
36 . querySelector ( 'rootfile' )
37 . getAttribute ( 'full-path' );
39 console . debug ( `Detected root path of EPUB file: ${ rootPath } ` );
48 * Get key information about this fic from the unpacked EPUB file.
51 async 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`.
57 // This is the rough strucutre of the contents:
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>
67 // <item id="html4" href="A_Stab_Wars_Story_split_000.xhtml" media-type="application/xhtml+xml"/>
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' ),
82 // The title of the fic will be in a <dc:title> node, for example:
84 // <dc:title>Operation Cameo</dc:title>
86 const namespaceResolver = ( prefix ) =>
87 prefix === 'dc' ? 'http://purl.org/dc/elements/1.1/' : null ;
89 const title = containerOpfXmlDoc . evaluate (
93 XPathResult . STRING_TYPE
96 console . debug ( `Detected title of EPUB file: ${ title } ` );
98 // The author of the fic will be in a <dc:author> node, for example:
100 // <dc:creator …>alexwlchan</dc:creator>
102 const author = containerOpfXmlDoc . evaluate (
106 XPathResult . STRING_TYPE
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:
116 // href="A_Stab_Wars_Story_split_000.xhtml"
117 // media-type="application/xhtml+xml"/>
119 const firstHtmlPath =
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
128 const firstHtmlDoc = parser . parseFromString (
129 await zip . file ( firstHtmlPath ). async ( 'string' ),
133 // Look for the contents of a <dl> which contains some metadata
136 // We're interested in the <dd> after "Fandom:"
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>
149 Array . from ( firstHtmlDoc . querySelectorAll ( 'dt' ))
150 . find ( dt => dt . innerText === 'Fandom:' || dt . innerText === 'Fandoms:' )
155 console . debug ( `Detected fandom in first HTML file: ${ fandom } ` );
157 return { title : decodeXML ( title ), author , fandom };
163 * Decode the XML entities in a string.
165 function decodeXML ( input ) {
166 if ( /&|"|'|'<|>/ . test ( input )) {
167 var doc = new DOMParser (). parseFromString ( input , "text/html" );
168 return doc . documentElement . textContent ;
176 * Choose a colour to represent the fics in this fandom.
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.
184 function 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 ;
203 let [ red1 , green1 , blue1 ] = hslToRgb ( hue , saturation , lightness1 );
204 let [ red2 , green2 , blue2 ] = hslToRgb ( hue , saturation , lightness2 );
206 // Convert to a hex string.
208 `# ${ numToHex ( red1 ) }${ numToHex ( green1 ) }${ numToHex ( blue1 ) } ` ,
209 `# ${ numToHex ( red2 ) }${ numToHex ( green2 ) }${ numToHex ( blue2 ) } ` ,
216 * A seeded implementation of a random number generator in JavaScript.
218 * Written by Stack Overflow user bryc:
219 * https://stackoverflow.com/a/47593316/1558022
221 function 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 );
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 ];
239 function sfc32 ( a , b , c , d ) {
241 a |= 0 ; b |= 0 ; c |= 0 ; d |= 0 ;
242 let t = ( a + b | 0 ) + d | 0 ;
245 b = c + ( c << 3 ) | 0 ;
246 c = ( c << 21 | c >>> 11 );
248 return ( t >>> 0 ) / 4294967296 ;
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].
261 * @param {number} h The hue
262 * @param {number} s The saturation
263 * @param {number} l The lightness
264 * @return {Array} The RGB representation
266 * This function is by Gary Tan and is taken from Stack Overflow:
267 * https://stackoverflow.com/a/9493060/1558022
269 function hslToRgb ( h , s , l ) {
273 r = g = b = l ; // achromatic
275 const q = l < 0.5 ? l * ( 1 + s ) : l + s - l * s ;
277 r = hueToRgb ( p , q , h + 1 / 3 );
278 g = hueToRgb ( p , q , h );
279 b = hueToRgb ( p , q , h - 1 / 3 );
282 return [ Math . round ( r * 255 ), Math . round ( g * 255 ), Math . round ( b * 255 )];
285 function hueToRgb ( p , q , t ) {
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 ;
297 * Convert a number to a 2-digit hex string.
299 function numToHex ( n ) {
300 const hex = n . toString ( 16 );
301 return hex . length === 1 ? '0' + hex : hex ;
307 * Create a PNG cover image for a book.
309 * Returns a <canvas> element for this cover.
311 function 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.
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
348 lineStart : height * 0.82 ,
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.
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 ,
372 lineStart : height * 0.18 ,
382 * Given a list of words, work out how to fit them into lines on
383 * a <canvas> without exceeding the max width.
385 function getLinesForWords ({ ctx , words , maxWidth , separator }) {
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 ;
401 lines . push ( currentLine );
402 currentLine = thisWord ;
406 // Remember to add a line for anything not already tracked.
407 lines . push ( currentLine );
409 return { lines , separator };
415 * Split a title into lines to fit into a <canvas> without wrapping.
417 function getTitleLines ({ ctx , title , maxWidth }) {
418 const { lines } = getLinesForWords ({
419 ctx , words : title . split ( " " ), maxWidth , separator : " "
428 * Split an author name into lines to fit into a <canvas>.
430 * We try to apply some intelligence when we need to break across lines,
431 * e.g. breaking on spaces or uppercase characters.
433 * We only have room for two lines of text in the author name, so
434 * anything beyond that gets truncated.
436 function 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 : " "
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 : "_"
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 ({
459 words : authorName . replace ( /([A-Z])/g , ' $1' ). trim (). split ( /\s+/ ),
465 // Otherwise, we just break the string into individual characters
466 // and fit as many as we can onto each line.
468 return getLinesForWords ({
470 words : [... authorName ],
481 * Add lines of text to a canvas.
483 * The text will be drawn in the middle of the page.
485 function 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 ] += '…' ;
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 ];
501 separator === " " ? thisLine
502 : separator === "_" ? thisLine + "_"
503 : lineno < lines . length - 1 ? thisLine + "-"
506 ctx . fillText ( displayLine , width / 2 , lineStart + lineno * lineHeight );
513 * Shuffle the elements of an array.
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
519 function 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 );
529 // And swap it with the current element.
530 [ array [ currentIndex ], array [ randomIndex ]] = [
531 array [ randomIndex ], array [ currentIndex ]];