Skip to main content

index.html

1<!DOCTYPE html>
2<html lang="en">
4<head>
5 <meta charset="utf-8">
6 <meta http-equiv="X-UA-Compatible" content="IE=edge">
7 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
9 <script src="static/app.js"></script>
11 <title>Add cover images to EPUBs from AO3</title>
13 <style>
14 :root {
15 --max-width: 42rem;
16 --padding: 1em;
17 }
19 body {
20 margin: 0;
21 padding: 0;
23 min-height: 100vh;
24 display: flex;
25 flex-direction: column;
27 font-family: sans-serif;
28 }
30 main {
31 flex: 1;
32 }
34 h1 {
35 margin-bottom: 0.75em;
36 }
38 header > *, main, footer > * {
39 width: var(--max-width);
40 max-width: calc(100% - 2 * var(--padding));
41 margin: 0 auto;
43 padding-top: calc(var(--padding) + env(safe-area-inset-top));
44 padding-right: calc(var(--padding) + env(safe-area-inset-right));
45 padding-bottom: calc(var(--padding) + env(safe-area-inset-bottom));
46 padding-left: calc(var(--padding) + env(safe-area-inset-left));
47 }
49 footer {
50 background: #ddd;
51 padding-bottom: calc(1.5 * var(--padding) + env(safe-area-inset-bottom));
52 font-size: small;
53 }
55 footer p:first-child { margin-top: 0; }
56 footer p:last-child { margin-bottom: 0; }
58 #results {
59 list-style-type: none;
60 margin: 0;
61 padding: 0;
62 overflow-x: scroll;
63 white-space: nowrap;
64 }
66 #results li {
67 display: inline-block;
68 margin-right: 1em;
69 }
71 canvas {
72 width: 150px;
73 }
75 input[type="file"] {
76 max-width: 100%;
77 }
79 button {
80 margin-top: 0.5em;
81 }
83 input[type="file"], button {
84 font-size: 1em;
85 }
87 form {
88 background: #eee;
89 padding: var(--padding);
90 border-radius: 10px;
91 }
93 form p:first-child {
94 margin-top: 0;
95 }
97 h2 {
98 margin-top: 3em;
99 }
101 dl, p, #errors li {
102 line-height: 1.5em;
103 }
105 dt {
106 font-weight: bold;
107 }
109 dt:not(:first-child) {
110 margin-top: var(--padding);
111 }
113 dd {
114 margin-left: calc(2 * var(--padding));
115 }
117 .version {
118 color: #999;
119 font-size: small;
120 }
122 .placeholder {
123 opacity: 0.4;
124 filter: grayscale(100%);
125 }
127 noscript {
128 color: red;
129 font-weight: bold;
130 }
132 #instructions {
133 display: none;
134 }
136 #errors {
137 color: red;
138 display: none;
139 margin-bottom: 0;
140 }
141 </style>
142</head>
144<body>
145 <main>
146 <h1>
147 Add cover images to EPUBs from AO3
148 <span class="version">v1.1.0</span>
149 </h1>
151 <noscript>
152 You need to enable JavaScript to use this tool.
153 </noscript>
155 <p>
156 <strong>Add auto-generated cover images to EPUB files you download from AO3.</strong>
157 </p>
159 <p>
160 Upload a file, and this site will add a colourful cover page based on the title and author.
161 This makes it easy to pick out your favourite fic!
162 </p>
164 <form id="uploadForm">
165 <p>
166 Upload an EPUB to add a cover image:
167 <input
168 type="file"
169 id="epubFileInput"
170 name="filename"
171 accept="application/epub+zip"
172 required
173 multiple
174 onchange="createNewCoverImages()">
175 </p>
177 <p id="instructions">&nbsp;</p>
179 <ul id="results"></ul>
181 <ul id="errors"></ul>
182 </form>
184 <script>
185 function reportError(message) {
186 document.querySelector('#errors').style.display = 'block';
188 document.querySelector('#errors').innerHTML +=
189 `<li><strong>Something went wrong:</strong> ${message}</li>`;
190 }
192 function createSingleCoverImage(file) {
193 console.debug(`Got file ${file.name}`);
195 JSZip.loadAsync(file)
196 .then(async function(zip) {
197 // Get the key information about this fic.
198 const ficInfo = await getKeyFicInfo(zip);
200 // Create a cover image for this book which shows the title
201 // and author on a solid colour background.
202 //
203 // Then add this cover image to the zip file.
204 const canvas = createCoverImage(ficInfo);
206 const blob = await new Promise(
207 resolve => canvas.toBlob(resolve, 'image/png')
208 );
210 zip.file('media/cover.png', blob, { base64: true });
212 // Now modify the `content.opf` file to insert this image
213 // as the cover, and replace the file in the zip.
214 //
215 // This sort of text-based replacement is utterly cheating,
216 // but it also works.
217 const contentOpfPath = await findContentOpfPath(zip)
218 const contentOpf = await zip.file(contentOpfPath).async('string');
220 const updatedContentOpf =
221 contentOpf
222 .replace(
223 "</manifest>",
224 '<item id="cover-image" properties="cover-image" href="media/cover.png" media-type="image/png"/></manifest>'
225 )
226 .replace(
227 "</metadata>",
228 '<meta name="cover" content="cover-image"/></metadata>'
229 );
231 zip.remove(contentOpfPath);
232 zip.file(contentOpfPath, updatedContentOpf);
234 // Create a download link for this zip file, and make
235 // the generated cover the link.
236 //
237 // Then add the link to the page, where the user can see it.
238 const zipContent = await zip.generateAsync({
239 type: 'blob',
240 mimeType: 'application/epub+zip',
241 });
243 const downloadLink = document.createElement('a');
244 downloadLink.href = URL.createObjectURL(zipContent);
245 downloadLink.download = file.name;
246 downloadLink.appendChild(canvas);
248 const listElement = document.createElement('li');
249 listElement.appendChild(downloadLink);
251 const resultsList = document.querySelector('ul#results');
252 resultsList.insertBefore(listElement, resultsList.firstChild);
254 // If there are no instructions on screen yet, add some.
255 document.querySelector('#instructions').style.display = 'block';
256 document.querySelector('#instructions').innerText = 'Tap or click to download your new EPUB:';
258 // We have a real example, so remove the placeholders.
259 document.querySelectorAll('.placeholder').forEach(
260 el => el.remove()
261 );
263 // Remove the file from the form, so the user can select
264 // another fic to upload.
265 uploadForm.querySelector('input[type="file"]').value = null;
266 })
267 .catch(function(error) {
268 reportError(`Unable to read EPUB file: ${error}`);
269 });
270 }
272 function createNewCoverImages() {
273 const fileInput = document.querySelector('#epubFileInput');
275 // Get the epub files from the file input.
276 for (var file of fileInput.files) {
277 createSingleCoverImage(file);
278 }
279 }
281 // When the window loads, add three randomly generated stories
282 // to give a sense of what the covers will look like.
283 window.addEventListener("DOMContentLoaded", function(event) {
284 const titles = [
285 "10 Things I Tagged About You",
286 "404: Canon Not Found",
287 "5 Things That Weren’t, and 1 That Was",
288 "A Series of Questionable Decisions",
289 "Beta’ing Bad",
290 "Canon, What Canon?",
291 "Game of Tropes",
292 "Gone With the WIP",
293 "Lord of the Files",
294 "Plan B (And C, And D)",
295 "Pride and Plot Bunnies",
296 "Sense and Shippability",
297 "Ship Happens",
298 "Spock and Awe",
299 "Tagged as ‘slow burn’ but less than 1k",
300 "The Adventures of Huckleberry Fan",
301 "The Fandom of the Opera",
302 "The Fast and the Fluffiest",
303 "The First Rule of Fic Club",
304 "The Kudos of Monte Cristo",
305 "The Taming of the Ship",
306 "The [Figure] of [Concept] and [Idea]",
307 "This Was Meant to Be a One-Shot",
308 "To All the Tropes I’ve Loved Before",
309 "Waiting for Goncharov",
310 "Yet Another Coffee Shop AU",
311 ];
313 const authors = [
314 "Amy Pond-ers",
315 "Ann Thology",
316 "Anne Onymous",
317 "Arthur Canon Doyle",
318 "Arya Snark",
319 "Bucky Barnes and Noble",
320 "Diana Prints",
321 "Edgar Allan Woe",
322 "Emily Ficinson",
323 "Faye N. Dom",
324 "Ian Flemingo",
325 "James T. Kink",
326 "Jane AUs-ten",
327 "Loki Laufeelson",
328 "Luke Skywriter",
329 "Jean-Luc Ficard",
330 "Mary Sue",
331 "Mr Milkshake",
332 "Oscar WildlyOOC",
333 "Pepper Plotts",
334 "William T. Riter",
335 ];
337 const fandoms = ["1", "10", "100", "10000", "10000000", "10000000000", "1000000000000000", "100000000000000000", "10000000000000000000", "1000000000000000000000", "100000000000000000000000", "10000000000000000000000000", "1", "10", "100", "10000", "10000000", "10000000000", "1000000000000000", "100000000000000000", "10000000000000000000", "1000000000000000000000", "100000000000000000000000", "10000000000000000000000000"];
339 shuffle(titles);
340 shuffle(authors);
341 shuffle(fandoms);
343 for (i = 0; i < 4; i++) {
344 const canvas = createCoverImage({
345 title: titles[i],
346 author: authors[i],
347 fandom: fandoms[i]
348 });
350 const listElement = document.createElement('li');
351 listElement.classList.add("placeholder");
352 listElement.appendChild(canvas);
354 const resultsList = document.querySelector('ul#results');
355 resultsList.insertBefore(listElement, resultsList.firstChild);
356 }
357 });
358 </script>
360 <h2>FAQs</h2>
362 <dl>
363 <dt>
364 Who made this?
365 </dt>
366 <dd>
367 It’s made by <a href="https://alexwlchan.net/">alexwlchan</a>.
368 If you find it useful, maybe <a href="https://ko-fi.com/alexwlchan">buy me a coffee</a>?
369 </dd>
371 <dt>
372 Can you see what fics I’m reading?
373 </dt>
374 <dd>
375 No!
376 This is completely private – everything stays on your phone/computer.
377 I don’t see any of the EPUBs you’re adding covers to.
378 </dd>
380 <dt>
381 How do you pick the colours?
382 </dt>
383 <dd>
384 The colours are chosen randomly, but they’ll be the same for all the fics in the same fandom.
385 For example, all your Star Wars stories will be red, while Star Trek is green.
386 This is to help you browse your stories by AO3 fandom.
387 </dd>
389 <dt>
390 How does it work?
391 </dt>
392 <dd>
393 I’ve written <a href="https://alexwlchan.net/2025/ao3-epub-covers/">a blog post</a> that explains how it works.
394 You can also “View source” on this web page and see all the code, or <a href="https://github.com/alexwlchan/add-cover-to-ao3-files">look at the GitHub repository</a>.
395 </dd>
397 <dt>
398 I love this tool! How can I make sure it doesn’t go away?
399 </dt>
400 <dd>
401 You can <a href="https://ko-fi.com/alexwlchan">buy me a coffee</a> which will make me feel happy and keep running my websites, or you can download this web page and get a copy that will run on your computer.
402 </dd>
404 <dt>
405 I like this tool, but I’d love it even more if it behaved differently!
406 How can I do that?
407 </dt>
408 <dd>
409 You can ask me nicely (file a <a href="https://github.com/alexwlchan/add-cover-to-ao3-files/issues/new?template=Blank+issue">GitHub issue</a> or <a href="mailto:alex@alexwlchan.net">send me an email</a>), or you can do it yourself!
410 This tool is only a single web page, so you can download it and then make your own changes to the HTML and JavaScript until it works the way you want.
411 </dd>
413 <dt>
414 I have a fic that doesn’t work.
415 Can you help?
416 </dt>
417 <dd>
418 Send me a copy of the file, or a link to the original fic, and the browser you were using when something broke.
419 </dd>
421 <dt>
422 Something is broken.
423 Can you fix it?
424 </dt>
425 <dd>
426 <a href="https://github.com/alexwlchan/add-cover-to-ao3-files/issues/new?template=Blank+issue">File an issue</a> in the GitHub repository, or <a href="mailto:alex@alexwlchan.net">send me an email</a>.
427 It’s useful if you tell me what fic you were looking at, what didn’t work, and what browser you were using.
428 </dd>
429 </dl>
431 <h2>Acknowledgements</h2>
433 <p>
434 This site uses the open source <a href="https://github.com/Stuk/jszip/tree/main">JSZip</a> library, which is written by Stuart Knightley, David Duponchel, Franz Buchinger, and António Afonso (plus other contributors).
435 Yay open source!
436 </p>
438 <p>
439 This tool relies on the Archive Of Our Own (AO3), which was created by the Organization of Transformative Works (OTW), but it’s not affiliated with or endorsed by either of them.
440 </p>
441 </main>
443 <!--
444 This is an inlined copy of https://stuk.github.io/jszip/,
445 used under the MIT license.
447 If you're reading the source code of this page, there's nothing
448 interesting beyond here.
450 If you want to understand how JSZip works, you'd be better off
451 reading the unminified source code.
452 -->
453 <script src="static/jszip.min.js"></script>
454</body>
456</html>