|
<!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 Der Prozess; Franz Kafka 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 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">‹</button>' + |
|
'<span class="count"></span>' + |
|
'<button class="next" title="Next cover" aria-label="Next cover">›</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 ↓</button>' + |
|
'<button class="del" title="Remove this book" aria-label="Remove">×</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, "&").replace(/</g, "<"); |
|
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> |