Skip to content

Instantly share code, notes, and snippets.

@gregmuellegger
Last active July 19, 2026 11:50
Show Gist options
  • Select an option

  • Save gregmuellegger/de05db4cddd69d31739b7ebf6f6ce55b to your computer and use it in GitHub Desktop.

Select an option

Save gregmuellegger/de05db4cddd69d31739b7ebf6f6ce55b to your computer and use it in GitHub Desktop.
An offline bookshelf creator

Offline Shelf

Generate placeholder PDFs for hardcover books you own, so they show up in your ebook library as a reminder that a physical copy exists.

Open via gistpreview

Usage

  1. Enter books in the text field, one per line:
    • Free text: left hand of darkness le guin — title/author/filename are taken from the matched Open Library result
    • Explicit: Title | Author or Title ; Author — keeps exactly what you typed
  2. Find covers searches Open Library. Use ‹ › to switch between candidate covers; title, author, and filename follow the selection for free-text entries.
  3. Download per book (PDF ↓) or everything as a ZIP.

Output

  • One single-page PDF per book, named Title, Author.pdf
  • Page sized to the cover's aspect ratio at 6 in width, cover full bleed
  • Yellow OFFLINE badge in the bottom-right corner
  • Title/author embedded as PDF metadata (picked up by Calibre & co. on import)
  • Books without a cover get a generated text cover

How it works

Covers are fetched from Open Library (CORS-friendly, works from file://), rendered to a canvas with the badge, re-encoded as JPEG, and wrapped in a hand-rolled minimal PDF. The ZIP (store-only) and CRC32 are also implemented inline in vanilla JS.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Offline Shelf — placeholder PDFs for your hardcover library</title>
<style>
:root{
--ink:#171310;
--panel:#211c15;
--panel-2:#2a2419;
--line:#3b3426;
--text:#efe7d4;
--muted:#a2977e;
--accent:#f2c41d;
--accent-ink:#1a1400;
--err:#e06a4f;
--ok:#93b877;
--serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
--mono:ui-monospace,"SF Mono","JetBrains Mono","Cascadia Code",Menlo,Consolas,monospace;
--sans:system-ui,-apple-system,"Segoe UI",sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0}
body{
background:var(--ink);
color:var(--text);
font-family:var(--sans);
font-size:15px;
line-height:1.5;
min-height:100vh;
}
a{color:var(--accent)}
header{
padding:34px 28px 10px;
max-width:1280px;margin:0 auto;
display:flex;align-items:baseline;gap:16px;flex-wrap:wrap;
}
h1{
font-family:var(--serif);
font-weight:600;
font-size:clamp(26px,4vw,38px);
letter-spacing:.01em;
margin:0;
}
h1 .sticker{
display:inline-block;
background:var(--accent);
color:var(--accent-ink);
font-family:var(--sans);
font-weight:800;
font-size:.38em;
letter-spacing:.14em;
padding:.35em .7em .3em;
border-radius:.35em;
transform:rotate(-4deg) translateY(-.4em);
box-shadow:0 2px 0 rgba(0,0,0,.35);
}
header p{margin:0;color:var(--muted);font-size:14px}
main{
max-width:1280px;margin:0 auto;
padding:18px 28px 80px;
display:grid;
grid-template-columns:340px 1fr;
gap:28px;
align-items:start;
}
@media (max-width:880px){
main{grid-template-columns:1fr}
header{padding:26px 18px 6px}
main{padding:14px 18px 60px}
}
.panel{
background:var(--panel);
border:1px solid var(--line);
border-radius:10px;
padding:18px;
position:sticky;top:18px;
}
@media (max-width:880px){ .panel{position:static} }
.panel label{
display:block;
font-size:11px;letter-spacing:.12em;text-transform:uppercase;
color:var(--muted);
margin-bottom:8px;
}
textarea{
width:100%;
min-height:220px;
resize:vertical;
background:var(--ink);
color:var(--text);
border:1px solid var(--line);
border-radius:6px;
padding:10px 12px;
font-family:var(--mono);
font-size:13px;
line-height:1.7;
}
textarea:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
.hint{color:var(--muted);font-size:12.5px;margin:10px 0 0}
.hint code{font-family:var(--mono);color:var(--text);background:var(--panel-2);padding:1px 5px;border-radius:4px}
.btnrow{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap}
button{
font:inherit;
font-weight:600;
border:none;border-radius:6px;
padding:10px 16px;
cursor:pointer;
background:var(--panel-2);
color:var(--text);
border:1px solid var(--line);
}
button:hover{border-color:var(--muted)}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
button.primary{
background:var(--accent);color:var(--accent-ink);border-color:transparent;
}
button.primary:hover{filter:brightness(1.06)}
button:disabled{opacity:.45;cursor:not-allowed;filter:none}
#status{margin-top:14px;font-size:13px;color:var(--muted);min-height:1.4em}
#status.err{color:var(--err)}
#status.ok{color:var(--ok)}
/* results */
#results{
display:grid;
grid-template-columns:repeat(auto-fill,minmax(220px,1fr));
gap:18px;
}
#empty{
color:var(--muted);
border:1px dashed var(--line);
border-radius:10px;
padding:40px 24px;
text-align:center;
font-size:14px;
}
.card{
background:var(--panel);
border:1px solid var(--line);
border-radius:10px;
overflow:hidden;
display:flex;flex-direction:column;
}
.thumb{
position:relative;
aspect-ratio:2/3;
background:var(--panel-2);
display:flex;align-items:center;justify-content:center;
overflow:hidden;
}
.thumb img{width:100%;height:100%;object-fit:cover;display:block}
.thumb .spinner{
width:26px;height:26px;border-radius:50%;
border:3px solid var(--line);border-top-color:var(--accent);
animation:spin 0.9s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){
.thumb .spinner{animation:none;border-top-color:var(--muted)}
}
.badge{
position:absolute;right:6%;bottom:4.5%;
background:#f5c518;color:#1a1200;
font-weight:800;font-size:11px;letter-spacing:.1em;
padding:4px 9px;border-radius:5px;
box-shadow:0 1px 3px rgba(0,0,0,.4);
pointer-events:none;
}
.textcover{
width:100%;height:100%;
display:flex;flex-direction:column;justify-content:center;align-items:center;
text-align:center;
padding:14% 10%;
border:6px double var(--accent);
border-radius:2px;
}
.textcover .t{font-family:var(--serif);font-size:17px;line-height:1.3}
.textcover .a{margin-top:14px;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}
.meta{padding:12px 14px 6px;flex:1}
.meta .t{font-family:var(--serif);font-size:16px;line-height:1.3}
.meta .a{color:var(--muted);font-size:13px;margin-top:2px}
.meta .f{
font-family:var(--mono);font-size:11px;color:var(--muted);
margin-top:8px;word-break:break-all;opacity:.85;
}
.meta .s{font-size:12px;margin-top:6px}
.meta .s.err{color:var(--err)}
.meta .s.ok{color:var(--ok)}
.cardrow{
display:flex;align-items:center;gap:6px;
padding:10px 10px 12px;
border-top:1px solid var(--line);
}
.cardrow button{padding:5px 10px;font-size:13px;border-radius:5px}
.cardrow .count{font-family:var(--mono);font-size:12px;color:var(--muted);min-width:44px;text-align:center}
.cardrow .grow{flex:1}
.cardrow .txt{font-size:12px}
.cardrow .txt.active{background:var(--accent);color:var(--accent-ink);border-color:transparent}
.cardrow .dl{font-size:12px;color:var(--accent);white-space:nowrap}
.cardrow .del{color:var(--err);border-color:transparent;background:none}
footer{
max-width:1280px;margin:0 auto;padding:0 28px 40px;
color:var(--muted);font-size:12.5px;
}
</style>
</head>
<body>
<header>
<h1>Offline Shelf <span class="sticker">OFFLINE</span></h1>
<p>Placeholder PDFs for hardcovers you own — covers via Open Library, everything runs in your browser.</p>
</header>
<main>
<section class="panel">
<label for="input">Books — one per line</label>
<textarea id="input" spellcheck="false" placeholder="left hand of darkness le guin&#10;Der Prozess; Franz Kafka&#10;Gödel, Escher, Bach | Douglas Hofstadter"></textarea>
<p class="hint">Two ways to enter: free text per line (title + author mixed — the matched result fills in title, author and filename), or explicit <code>Title | Author</code> / <code>Title ; Author</code> to keep exactly what you typed.</p>
<div class="btnrow">
<button id="btnSearch" class="primary">Find covers</button>
<button id="btnZip" disabled>Download ZIP</button>
</div>
<p id="status"></p>
</section>
<section>
<div id="empty">Enter your books on the left and press <strong>Find covers</strong>.<br>Each book becomes one PDF named <span style="font-family:var(--mono)">Title, Author.pdf</span>.</div>
<div id="results" hidden></div>
</section>
</main>
<footer>
Covers and search: <a href="https://openlibrary.org" target="_blank" rel="noopener">openlibrary.org</a>.
Save this file locally and open it in your browser — no server, no build, no libraries.
Page size follows each cover's aspect ratio at 6&nbsp;in width; books without a cover get a generated text cover.
</footer>
<script>
"use strict";
/* ================= core-start (PDF + ZIP, environment-agnostic) ================= */
/** CRC32 (for ZIP) */
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
/** Build a store-only (uncompressed) ZIP from [{name, data:Uint8Array}] */
function buildZip(files) {
const enc = new TextEncoder();
const now = new Date();
const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xFFFF;
const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xFFFF;
const localParts = [];
const centralParts = [];
let offset = 0;
for (const f of files) {
const nameBytes = enc.encode(f.name);
const crc = crc32(f.data);
const size = f.data.length;
const lh = new Uint8Array(30 + nameBytes.length);
const lv = new DataView(lh.buffer);
lv.setUint32(0, 0x04034b50, true);
lv.setUint16(4, 20, true); // version needed
lv.setUint16(6, 0x0800, true); // UTF-8 names
lv.setUint16(8, 0, true); // method: store
lv.setUint16(10, dosTime, true);
lv.setUint16(12, dosDate, true);
lv.setUint32(14, crc, true);
lv.setUint32(18, size, true);
lv.setUint32(22, size, true);
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true);
lh.set(nameBytes, 30);
const ch = new Uint8Array(46 + nameBytes.length);
const cv = new DataView(ch.buffer);
cv.setUint32(0, 0x02014b50, true);
cv.setUint16(4, 20, true); // made by
cv.setUint16(6, 20, true); // needed
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, 0, true);
cv.setUint16(12, dosTime, true);
cv.setUint16(14, dosDate, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, size, true);
cv.setUint32(24, size, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint32(42, offset, true); // local header offset
ch.set(nameBytes, 46);
localParts.push(lh, f.data);
centralParts.push(ch);
offset += lh.length + size;
}
const centralSize = centralParts.reduce((s, p) => s + p.length, 0);
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, files.length, true);
ev.setUint16(10, files.length, true);
ev.setUint32(12, centralSize, true);
ev.setUint32(16, offset, true);
const total = offset + centralSize + 22;
const out = new Uint8Array(total);
let p = 0;
for (const part of [...localParts, ...centralParts, eocd]) { out.set(part, p); p += part.length; }
return out;
}
/** UTF-16BE hex string for PDF metadata (handles umlauts etc.) */
function pdfHexString(str) {
let hex = "FEFF";
for (let i = 0; i < str.length; i++) {
hex += str.charCodeAt(i).toString(16).padStart(4, "0").toUpperCase();
}
return "<" + hex + ">";
}
/**
* Wrap one baseline JPEG into a single-page PDF.
* Page size: 6 in (432 pt) wide, height follows the image aspect ratio.
*/
function buildPdf(jpegBytes, imgW, imgH, title, author) {
const enc = new TextEncoder();
const pageW = 432;
const pageH = Math.round((432 * imgH / imgW) * 100) / 100;
const parts = [];
let offset = 0;
const offsets = [0, 0, 0, 0, 0, 0, 0]; // 1-indexed, objects 1..6
const push = (b) => { parts.push(b); offset += b.length; };
const pushStr = (s) => push(enc.encode(s));
const mark = (n) => { offsets[n] = offset; };
pushStr("%PDF-1.4\n");
push(new Uint8Array([0x25, 0xE2, 0xE3, 0xCF, 0xD3, 0x0A])); // binary marker
mark(1);
pushStr("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
mark(2);
pushStr("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
mark(3);
pushStr(
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + pageW + " " + pageH + "] " +
"/Resources << /XObject << /Im0 4 0 R >> /ProcSet [/PDF /ImageC] >> /Contents 5 0 R >>\nendobj\n"
);
mark(4);
pushStr(
"4 0 obj\n<< /Type /XObject /Subtype /Image /Width " + imgW + " /Height " + imgH + " " +
"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " + jpegBytes.length + " >>\nstream\n"
);
push(jpegBytes);
pushStr("\nendstream\nendobj\n");
const content = "q " + pageW + " 0 0 " + pageH + " 0 0 cm /Im0 Do Q";
const contentBytes = enc.encode(content);
mark(5);
pushStr("5 0 obj\n<< /Length " + contentBytes.length + " >>\nstream\n");
push(contentBytes);
pushStr("\nendstream\nendobj\n");
mark(6);
pushStr(
"6 0 obj\n<< /Title " + pdfHexString(title) +
(author ? " /Author " + pdfHexString(author) : "") +
" /Producer (Offline Shelf) >>\nendobj\n"
);
const xrefStart = offset;
let xref = "xref\n0 7\n0000000000 65535 f \n";
for (let n = 1; n <= 6; n++) xref += String(offsets[n]).padStart(10, "0") + " 00000 n \n";
pushStr(xref);
pushStr("trailer\n<< /Size 7 /Root 1 0 R /Info 6 0 R >>\nstartxref\n" + xrefStart + "\n%%EOF\n");
const out = new Uint8Array(offset);
let p = 0;
for (const part of parts) { out.set(part, p); p += part.length; }
return out;
}
/* ================= core-end ================= */
/* ---------------- app state ---------------- */
const $ = (sel, el) => (el || document).querySelector(sel);
const els = {
input: $("#input"),
search: $("#btnSearch"),
zip: $("#btnZip"),
status: $("#status"),
results: $("#results"),
empty: $("#empty"),
};
let books = []; // {title, author, covers:[coverId], sel, useText, status, card}
let busy = false;
const COVER_M = (id) => "https://covers.openlibrary.org/b/id/" + id + "-M.jpg";
const COVER_L = (id) => "https://covers.openlibrary.org/b/id/" + id + "-L.jpg";
function setStatus(msg, cls) {
els.status.textContent = msg || "";
els.status.className = cls || "";
}
function parseInput(text) {
const out = [];
for (const raw of text.split("\n")) {
const line = raw.trim();
if (!line) continue;
const idx = [line.indexOf("|"), line.indexOf(";")].filter(i => i >= 0);
if (idx.length) {
const i = Math.min(...idx);
const title = line.slice(0, i).trim();
const author = line.slice(i + 1).trim();
if (title) out.push({ title, author, freeText: false, covers: [], sel: 0, useText: false, status: "queued" });
} else {
out.push({ title: line, author: "", freeText: true, covers: [], sel: 0, useText: false, status: "queued" });
}
}
return out;
}
/** Effective title/author: for free-text entries, taken from the selected Open Library match. */
function effTitleAuthor(b) {
if (b.freeText && b.covers.length) {
const c = b.covers[Math.min(b.sel, b.covers.length - 1)];
return { title: c.title || b.title, author: c.author || "" };
}
return { title: b.title, author: b.author };
}
function sanitizeFilename(name) {
return name
.replace(/[\/\\:*?"<>|\u0000-\u001f]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 150)
.replace(/[. ]+$/, "");
}
function baseFilename(b) {
const e = effTitleAuthor(b);
return sanitizeFilename(e.author ? e.title + ", " + e.author : e.title) + ".pdf";
}
/* ---------------- Open Library search ---------------- */
async function searchCovers(book) {
const params = new URLSearchParams({
fields: "title,author_name,cover_i",
limit: "15",
});
if (book.freeText) {
params.set("q", book.title);
} else {
params.set("title", book.title);
if (book.author) params.set("author", book.author);
}
const url = "https://openlibrary.org/search.json?" + params.toString();
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
const seen = new Set();
const covers = [];
for (const doc of data.docs || []) {
if (doc.cover_i && !seen.has(doc.cover_i)) {
seen.add(doc.cover_i);
covers.push({
id: doc.cover_i,
title: doc.title || "",
author: (doc.author_name || [])[0] || "",
});
if (covers.length >= 8) break;
}
}
return covers;
}
async function runSearch() {
if (busy) return;
books = parseInput(els.input.value);
if (!books.length) { setStatus("Nothing to search — enter at least one line.", "err"); return; }
busy = true;
els.search.disabled = true;
els.zip.disabled = true;
els.empty.hidden = true;
els.results.hidden = false;
els.results.innerHTML = "";
for (const b of books) renderCard(b);
let found = 0;
for (let i = 0; i < books.length; i++) {
const b = books[i];
b.status = "searching";
updateCard(b);
setStatus("Searching " + (i + 1) + " / " + books.length + " — " + b.title);
try {
b.covers = await searchCovers(b);
b.status = b.covers.length ? "found" : "none";
if (b.covers.length) found++;
} catch (e) {
b.covers = [];
b.status = "error";
}
b.sel = 0;
updateCard(b);
if (i < books.length - 1) await new Promise(r => setTimeout(r, 350)); // be gentle to the API
}
busy = false;
els.search.disabled = false;
els.zip.disabled = false;
const misses = books.length - found;
setStatus(
"Done: " + found + " with cover" + (misses ? ", " + misses + " → text cover" : "") +
". Check the previews, then download.",
"ok"
);
}
/* ---------------- cards ---------------- */
function renderCard(b) {
const card = document.createElement("div");
card.className = "card";
card.innerHTML =
'<div class="thumb"></div>' +
'<div class="meta">' +
'<div class="t"></div><div class="a"></div><div class="f"></div><div class="s"></div>' +
"</div>" +
'<div class="cardrow">' +
'<button class="prev" title="Previous cover" aria-label="Previous cover">&lsaquo;</button>' +
'<span class="count"></span>' +
'<button class="next" title="Next cover" aria-label="Next cover">&rsaquo;</button>' +
'<span class="grow"></span>' +
'<button class="txt" title="Use a generated text cover instead">Text</button>' +
'<button class="dl" title="Download this book as a single PDF">PDF &darr;</button>' +
'<button class="del" title="Remove this book" aria-label="Remove">&times;</button>' +
"</div>";
$(".prev", card).addEventListener("click", () => { if (b.covers.length) { b.sel = (b.sel - 1 + b.covers.length) % b.covers.length; updateCard(b); } });
$(".next", card).addEventListener("click", () => { if (b.covers.length) { b.sel = (b.sel + 1) % b.covers.length; updateCard(b); } });
$(".txt", card).addEventListener("click", () => { b.useText = !b.useText; updateCard(b); });
$(".dl", card).addEventListener("click", () => downloadSinglePdf(b));
$(".del", card).addEventListener("click", () => {
books = books.filter(x => x !== b);
card.remove();
if (!books.length) { els.results.hidden = true; els.empty.hidden = false; els.zip.disabled = true; }
});
b.card = card;
els.results.appendChild(card);
updateCard(b);
}
function textCoverPreviewHtml(eff) {
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
return '<div class="textcover"><div class="t">' + esc(eff.title) + "</div>" +
(eff.author ? '<div class="a">' + esc(eff.author) + "</div>" : "") + "</div>";
}
function updateCard(b) {
const card = b.card;
const thumb = $(".thumb", card);
const s = $(".s", card);
const eff = effTitleAuthor(b);
const showText = b.useText || (b.status !== "searching" && b.status !== "queued" && !b.covers.length);
$(".t", card).textContent = eff.title;
$(".a", card).textContent = eff.author || "—";
$(".f", card).textContent = baseFilename(b);
if (b.status === "searching" || b.status === "queued") {
thumb.innerHTML = '<div class="spinner" role="status" aria-label="Searching"></div>';
s.textContent = b.status === "searching" ? "Searching Open Library…" : "Queued…";
s.className = "s";
} else if (showText) {
thumb.innerHTML = textCoverPreviewHtml(eff) + '<div class="badge">OFFLINE</div>';
s.textContent = b.useText ? "Generated text cover (chosen)."
: b.status === "error" ? "Search failed — using text cover."
: "No cover found — using text cover.";
s.className = b.status === "error" ? "s err" : "s";
} else {
thumb.innerHTML = '<img alt="" loading="lazy"><div class="badge">OFFLINE</div>';
const img = $("img", thumb);
img.src = COVER_M(b.covers[b.sel].id);
img.alt = "Cover: " + eff.title;
s.textContent = b.covers.length + " match" + (b.covers.length > 1 ? "es" : "") + " found.";
s.className = "s ok";
}
$(".count", card).textContent = b.covers.length ? (b.sel + 1) + " / " + b.covers.length : "– / –";
$(".prev", card).disabled = showText || b.covers.length < 2;
$(".next", card).disabled = showText || b.covers.length < 2;
const txtBtn = $(".txt", card);
txtBtn.classList.toggle("active", b.useText);
txtBtn.disabled = !b.covers.length && b.status !== "found";
$(".dl", card).disabled = (b.status === "queued" || b.status === "searching");
}
/* ---------------- rendering to canvas ---------------- */
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("image load failed"));
img.src = url;
});
}
function drawBadge(ctx, W, H) {
const bw = Math.round(W * 0.26);
const bh = Math.round(bw * 0.34);
const m = Math.round(W * 0.05);
const x = W - bw - m, y = H - bh - m, r = Math.round(bh * 0.2);
ctx.save();
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + bw, y, x + bw, y + bh, r);
ctx.arcTo(x + bw, y + bh, x, y + bh, r);
ctx.arcTo(x, y + bh, x, y, r);
ctx.arcTo(x, y, x + bw, y, r);
ctx.closePath();
ctx.shadowColor = "rgba(0,0,0,0.35)";
ctx.shadowBlur = bh * 0.15;
ctx.shadowOffsetY = bh * 0.08;
ctx.fillStyle = "#f5c518";
ctx.fill();
ctx.shadowColor = "transparent";
ctx.fillStyle = "#1a1200";
ctx.font = "800 " + Math.round(bh * 0.42) + "px " + '-apple-system, "Segoe UI", Arial, sans-serif';
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("O F F L I N E", x + bw / 2, y + bh / 2 + bh * 0.03);
ctx.restore();
}
function wrapText(ctx, text, maxWidth) {
const words = text.split(/\s+/);
const lines = [];
let line = "";
for (const w of words) {
const t = line ? line + " " + w : w;
if (ctx.measureText(t).width > maxWidth && line) { lines.push(line); line = w; }
else line = t;
}
if (line) lines.push(line);
return lines;
}
function renderTextCover(b) {
const eff = effTitleAuthor(b);
const W = 900, H = 1350;
const c = document.createElement("canvas");
c.width = W; c.height = H;
const ctx = c.getContext("2d");
ctx.fillStyle = "#201a12";
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = "#f2c41d";
ctx.lineWidth = 6;
ctx.strokeRect(36, 36, W - 72, H - 72);
ctx.lineWidth = 2;
ctx.strokeRect(54, 54, W - 108, H - 108);
ctx.fillStyle = "#efe7d4";
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
let size = 72;
ctx.font = size + 'px "Iowan Old Style", "Palatino Linotype", Georgia, serif';
let lines = wrapText(ctx, eff.title, W * 0.72);
while (lines.length > 6 && size > 40) {
size -= 6;
ctx.font = size + 'px "Iowan Old Style", "Palatino Linotype", Georgia, serif';
lines = wrapText(ctx, eff.title, W * 0.72);
}
const lineH = size * 1.25;
let y = H * 0.42 - (lines.length - 1) * lineH / 2;
for (const ln of lines) { ctx.fillText(ln, W / 2, y); y += lineH; }
if (eff.author) {
ctx.fillStyle = "#a2977e";
ctx.font = '600 34px -apple-system, "Segoe UI", Arial, sans-serif';
const authorText = eff.author.toUpperCase().split("").join("\u200a");
ctx.fillText(authorText, W / 2, y + 40);
}
drawBadge(ctx, W, H);
return c;
}
async function renderCoverCanvas(b) {
if (!b.useText && b.covers.length) {
try {
const img = await loadImage(COVER_L(b.covers[b.sel].id));
let W = img.naturalWidth, H = img.naturalHeight;
if (W < 10 || H < 10) throw new Error("empty cover");
if (W < 560) { const f = 560 / W; W = Math.round(W * f); H = Math.round(H * f); }
const c = document.createElement("canvas");
c.width = W; c.height = H;
const ctx = c.getContext("2d");
ctx.imageSmoothingQuality = "high";
ctx.drawImage(img, 0, 0, W, H);
drawBadge(ctx, W, H);
return c;
} catch (e) {
// fall through to text cover
}
}
return renderTextCover(b);
}
function canvasToJpegBytes(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob(async (blob) => {
if (!blob) return reject(new Error("JPEG encode failed"));
resolve(new Uint8Array(await blob.arrayBuffer()));
}, "image/jpeg", 0.9);
});
}
/* ---------------- downloads ---------------- */
async function renderPdfBytes(b) {
const canvas = await renderCoverCanvas(b);
const jpeg = await canvasToJpegBytes(canvas);
const eff = effTitleAuthor(b);
return buildPdf(jpeg, canvas.width, canvas.height, eff.title, eff.author);
}
function saveBlob(blob, filename) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 30000);
}
async function downloadSinglePdf(b) {
if (busy) return;
busy = true;
els.zip.disabled = true;
els.search.disabled = true;
try {
setStatus("Rendering — " + b.title);
const pdf = await renderPdfBytes(b);
const name = baseFilename(b);
saveBlob(new Blob([pdf], { type: "application/pdf" }), name);
setStatus(name + " downloaded.", "ok");
} catch (e) {
console.error(e);
setStatus("PDF failed: " + e.message, "err");
} finally {
busy = false;
els.zip.disabled = !books.length;
els.search.disabled = false;
}
}
async function downloadZip() {
if (busy || !books.length) return;
busy = true;
els.zip.disabled = true;
els.search.disabled = true;
try {
const files = [];
const used = new Set();
for (let i = 0; i < books.length; i++) {
const b = books[i];
setStatus("Rendering " + (i + 1) + " / " + books.length + " — " + b.title);
const pdf = await renderPdfBytes(b);
let name = baseFilename(b);
if (used.has(name.toLowerCase())) {
const stem = name.slice(0, -4);
let n = 2;
while (used.has((stem + " (" + n + ").pdf").toLowerCase())) n++;
name = stem + " (" + n + ").pdf";
}
used.add(name.toLowerCase());
files.push({ name, data: pdf });
}
setStatus("Packing ZIP…");
const zip = buildZip(files);
saveBlob(new Blob([zip], { type: "application/zip" }), "offline-shelf.zip");
setStatus(files.length + " PDF" + (files.length > 1 ? "s" : "") + " packed. ZIP downloaded.", "ok");
} catch (e) {
console.error(e);
setStatus("ZIP failed: " + e.message, "err");
} finally {
busy = false;
els.zip.disabled = false;
els.search.disabled = false;
}
}
els.search.addEventListener("click", runSearch);
els.zip.addEventListener("click", downloadZip);
els.input.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") runSearch();
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment