diora-web/static/js/gencover.js
mrwnslz 8f5be379fe Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht)
- Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das
  Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“
  (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im
  verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen
  (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021,
  übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge
  (/feeds/folders/order/).
- Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +;
  Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte;
  Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste.
- Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem
  E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel
  verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie
  auf E-Ink.
- Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor,
  Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie,
  Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster,
  Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau
  und Details – einzigartig, überall gleich, nichts gespeichert.
- Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00

396 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Covers for books that have none (docs/library.md): drawn here, the same on every
* device, from what the book is called and where it stands — its title, author, shelf
* category and folders. Words in them point to a design concept (a motif, curated
* palettes, a typeface: Marx and Kapital to constructivism, Hegel and Dialektik to quiet
* geometry, Meer to waves, Krieg to map contours …); a hash of title and author picks
* the palette, the layout and every detail of the motif, so no two books look alike and
* each always looks the same. Nothing is stored or sent: it's an SVG, made when needed.
*
* DioraGenCover.url(book, {gray}) a data: URL (book: {title, author, shelfTag, folders})
* DioraGenCover.theme(book) the concept chosen (for tests)
*
* Chrome-61-safe: no ?. / ??.
*/
(function () {
'use strict';
const W = 300, H = 450;
// ---- chance, the same for the same book ------------------------------------------------
function hash(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
function rng(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const pick = (r, list) => list[Math.floor(r() * list.length)];
const between = (r, a, b) => a + r() * (b - a);
const f1 = n => Math.round(n * 10) / 10;
// ---- the concepts ---------------------------------------------------------------------------
// words: stems found anywhere in the text (lower case, umlauts as they are);
// palettes: [background, ink, accent, second accent], curated to sit well together.
const SERIF = "Georgia, 'Times New Roman', serif";
const SANS = "'Helvetica Neue', Helvetica, Arial, sans-serif";
const NARROW = "'Arial Narrow', 'Roboto Condensed', 'Helvetica Neue', Arial, sans-serif";
const MONO = "'Courier New', Courier, monospace";
const THEMES = [
{id: 'konstruktiv', font: NARROW, upper: true,
words: ['marx', 'kapital', 'capital', 'arbeit', 'labor', 'labour', 'revolution', 'sozialis', 'socialis', 'kommun', 'communis',
'ökonom', 'econom', 'klasse', 'class', 'gewerkschaft', 'streik', 'strike', 'partei', 'lenin', 'luxemburg', 'engels',
'proletar', 'faschis', 'fascis', 'jacobin', 'gegenstandpunkt', 'demokratie', 'herrschaft', 'massen'],
palettes: [['#efe6d2', '#1b1b1b', '#c8322b', '#1b1b1b'], ['#1d1d1d', '#f2ead7', '#d23a2c', '#e8b23a'], ['#e9dfc8', '#22252a', '#b5302a', '#7a8a8f']]},
{id: 'geometrie', font: SERIF,
words: ['philosoph', 'hegel', 'kant', 'dialekt', 'dialect', 'vernunft', 'reason', 'geist', 'spirit', 'ethik', 'ethic',
'logik', 'logic', 'metaphys', 'phänomen', 'phenomen', 'denken', 'wahrheit', 'truth', 'sein', 'nichts', 'nothing',
'kritik', 'critique', 'theorie', 'theory', 'begriff', 'wissen'],
palettes: [['#f1ece1', '#2b2a27', '#b08a3e', '#6f7d72'], ['#26292e', '#ece4d4', '#c9a25a', '#8a9aa8'], ['#e7e1d6', '#3a3833', '#8c5a3c', '#5d6b74']]},
{id: 'psyche', font: SERIF,
words: ['lacan', 'freud', 'psycho', 'sex', 'begehren', 'desire', 'disavowal', 'verleugn', 'unbewusst', 'unconscious',
'comedy', 'komik', 'komöd', 'parallax', 'real', 'voice', 'stimme', 'žižek', 'zizek', 'zupan', 'dolar', 'traum', 'dream',
'odd one', 'antigone', 'let them rot', 'klavierspielerin'],
palettes: [['#f3e3d9', '#3d2440', '#c45b6a', '#5c8a8a'], ['#2c2236', '#f2dfd2', '#e07a6f', '#7fb3ae'], ['#e8ddeb', '#2e2a3d', '#8d4f8a', '#d99a6c']]},
{id: 'wellen', font: SERIF,
words: ['meer', 'sea', 'ozean', 'ocean', 'wasser', 'water', 'insel', 'island', 'fluss', 'river', 'flut', 'flood', 'deluge',
'sintflut', 'schiff', 'ship', 'welle', 'wave', 'strand', 'küste', 'coast', 'regen', 'rain'],
palettes: [['#e5eef0', '#13324a', '#2f6f8f', '#8fb8c6'], ['#0f2a3d', '#e3edf0', '#4c95b0', '#a9d3dc'], ['#efe9dc', '#1f3b4d', '#3d7a8c', '#c98a5a']]},
{id: 'karte', font: SERIF, upper: true,
words: ['krieg', 'war', 'geschichte', 'history', 'empire', 'imperium', 'reich', 'zerstörung', 'destruction', 'wages',
'nazi', 'staat', 'state', 'nation', 'kolonial', 'colonial', 'weltkrieg', 'schlacht', 'battle', 'grenze', 'border',
'seeing like', 'world-system', 'weltsystem'],
palettes: [['#e9e1cc', '#3b3a2c', '#8a4b2a', '#6d7a4f'], ['#2f3127', '#e6dcc4', '#b5763c', '#8e9a6b'], ['#ddd5bd', '#2c2a22', '#7a3b2e', '#5f6f63']]},
{id: 'kosmos', font: SANS, upper: true,
words: ['stern', 'star', 'kosmos', 'cosmos', 'space', 'weltraum', 'planet', 'mond', 'moon', 'dune', 'nacht', 'night',
'universum', 'universe', 'zeit', 'time', 'licht', 'light', 'galax', 'messiah', 'science fiction', 'sci-fi', 'zukunft', 'future'],
palettes: [['#141833', '#ece6d3', '#e3b65b', '#7d8bd4'], ['#0e1424', '#dfe6f2', '#d9824a', '#6fa3c4'], ['#1f1a33', '#efe7da', '#c9a8e0', '#e3c46b']]},
{id: 'ornament', font: SERIF,
words: ['fantasy', 'drache', 'dragon', 'magie', 'magic', 'prophezei', 'prophecy', 'throne', 'thron', 'könig', 'king',
'krone', 'crown', 'schwert', 'sword', 'warrior', 'krieger', 'cats', 'katze', 'legende', 'legend', 'märchen', 'fairy',
'zauber', 'spell', 'ice and fire', 'bundle'],
palettes: [['#1e2a2a', '#efe3c4', '#c69a3c', '#8f3b46'], ['#2b1d2e', '#f0e2c8', '#d3a13f', '#3f7a6d'], ['#f0e6d0', '#2d2433', '#8c3a4a', '#2f6b65']]},
{id: 'botanik', font: SERIF,
words: ['natur', 'nature', 'wald', 'forest', 'baum', 'tree', 'garten', 'garden', 'blume', 'flower', 'blatt', 'leaf',
'frühling', 'spring', 'sommer', 'summer', 'pflanze', 'plant', 'wurzel', 'root', 'gras', 'grass', 'pilz', 'mushroom'],
palettes: [['#eef0e4', '#23301f', '#4f7a43', '#b7a35a'], ['#203024', '#e8ecdc', '#86a86b', '#d6b56a'], ['#f2ece0', '#2b3326', '#6f8f5a', '#c27c5a']]},
{id: 'berge', font: SANS, upper: true,
words: ['berg', 'mountain', 'alpen', 'alps', 'gipfel', 'peak', 'zauberberg', 'hügel', 'hill', 'landschaft', 'landscape',
'schnee', 'snow', 'winter', 'eis', 'ice', 'tal', 'valley', 'wandern'],
palettes: [['#e8ecef', '#22303b', '#5b7a8f', '#a9bccb'], ['#24323d', '#eef1f2', '#7f9fb3', '#c8d6de'], ['#efe9e0', '#2e3438', '#6f8590', '#c7a27a']]},
{id: 'zellen', font: SANS,
words: ['biolog', 'gehirn', 'brain', 'mensch', 'human', 'behave', 'verhalten', 'behavio', 'sucht', 'addict', 'enough',
'körper', 'body', 'medizin', 'medic', 'gen', 'evolution', 'neuro', 'zelle', 'cell', 'leben', 'life', 'tier', 'animal'],
palettes: [['#f1ebe4', '#26323a', '#e0765c', '#4f9a94'], ['#1f2b31', '#eef0ea', '#f08a6c', '#69b3aa'], ['#eaf0ee', '#22303a', '#d4634e', '#3f8f8a']]},
{id: 'raster', font: SANS,
words: ['stadt', 'city', 'urban', 'risiko', 'risk', 'gesellschaft', 'society', 'moderne', 'modern', 'system', 'analys',
'netz', 'network', 'technik', 'techn', 'smart', 'notes', 'notiz', 'daten', 'data', 'digital', 'maschine', 'machine',
'architektur', 'architect', 'hyperpolitik', 'politik', 'politic', 'revolution of', 'end of the'],
palettes: [['#ecebe7', '#1f2328', '#d1453b', '#2f5f8f'], ['#22262b', '#ebe8e1', '#e2b33c', '#4f86b8'], ['#f0ede6', '#2a2d31', '#3f6fa0', '#d9a03a']]},
{id: 'schatten', font: NARROW, upper: true,
words: ['tod', 'death', 'mord', 'murder', 'crime', 'verbrechen', 'schatten', 'shadow', 'dunkel', 'dark', 'angst', 'fear',
'serotonin', 'blut', 'blood', 'gift', 'poison', 'detektiv', 'detective', 'kommissar', 'thriller'],
palettes: [['#151515', '#ecebe6', '#c2302f', '#5a5a5a'], ['#ecebe6', '#151515', '#a8241f', '#7c7c7c'], ['#1c1f24', '#e4e0d8', '#d0a04a', '#55606b']]},
{id: 'typo', font: SERIF,
words: ['roman', 'novel', 'erzählung', 'stories', 'geschichten', 'gedicht', 'poem', 'poesie', 'poetry', 'brief', 'letters',
'tagebuch', 'diary', 'molloy', 'beckett', 'manuskripte', 'literatur', 'literature', 'review', 'zeitschrift'],
palettes: [['#f2ede3', '#211f1c', '#9b3b2f', '#7d7466'], ['#24211e', '#f0e9dc', '#d39b5f', '#8f8578'], ['#e9e4f0', '#26222e', '#5b4a8a', '#a3909a']]},
];
const DEFAULT = {id: 'bauhaus', font: SANS,
palettes: [['#efe9dd', '#24262a', '#d0533f', '#3f7fb0'], ['#26282d', '#efe8da', '#e3b04b', '#5b9aa8'], ['#e8ecef', '#22272c', '#c8553d', '#6b8f71'],
['#f3e9e2', '#2a2523', '#b84a62', '#4c7a8a']]};
// ---- which concept: the words, weighted by where they stand -------------------------------
function norm(s) { return String(s || '').toLowerCase(); }
function scored(book) {
// The title says most about a book; author and shelf category help, a folder only tips the scales.
const fields = [[norm(book.title), 3], [norm(book.shelfTag), 2],
[norm((book.folders || (book.folder ? [book.folder] : [])).join(' ')), 1], [norm(book.author), 2]];
return THEMES.map(t => {
let score = 0;
t.words.forEach(w => fields.forEach(f => { if (f[0].indexOf(w) >= 0) score += f[1]; }));
return {t, score};
}).filter(x => x.score > 0).sort((a, b) => b.score - a.score);
}
function theme(book) {
const s = scored(book);
return s.length ? s[0].t : DEFAULT;
}
// ---- motifs: each draws into the whole cover, the text gets its own space later -----------
const M = {
konstruktiv(r, p) {
let s = '';
const a = pick(r, [-34, -28, 28, 34, -45]);
s += `<g transform="rotate(${a} 150 225)">`;
for (let i = 0; i < 3; i++) {
const y = between(r, 60, 380), h = between(r, 14, 46);
s += `<rect x="-120" y="${f1(y)}" width="540" height="${f1(h)}" fill="${i ? p[1] : p[2]}" opacity="${i ? 0.9 : 1}"/>`;
}
s += '</g>';
s += `<circle cx="${f1(between(r, 70, 230))}" cy="${f1(between(r, 120, 320))}" r="${f1(between(r, 38, 78))}" fill="${p[2]}"/>`;
s += `<rect x="${f1(between(r, 20, 200))}" y="${f1(between(r, 40, 360))}" width="${f1(between(r, 30, 80))}" height="${f1(between(r, 30, 80))}" fill="none" stroke="${p[1]}" stroke-width="6"/>`;
return s;
},
geometrie(r, p) {
const cx = between(r, 100, 200), cy = between(r, 170, 300);
let s = '';
const n = 4 + Math.floor(r() * 5);
for (let i = 1; i <= n; i++) s += `<circle cx="${f1(cx)}" cy="${f1(cy)}" r="${f1(i * between(r, 14, 22))}" fill="none" stroke="${i % 3 ? p[1] : p[2]}" stroke-width="${i % 3 ? 1 : 2.4}" opacity="${i % 3 ? 0.55 : 0.9}"/>`;
const phi = 1.618;
s += `<line x1="0" y1="${f1(H / phi)}" x2="${W}" y2="${f1(H / phi)}" stroke="${p[1]}" stroke-width="1" opacity=".5"/>`;
s += `<line x1="${f1(W / phi)}" y1="0" x2="${f1(W / phi)}" y2="${H}" stroke="${p[1]}" stroke-width="1" opacity=".5"/>`;
s += `<circle cx="${f1(cx + between(r, -40, 40))}" cy="${f1(cy + between(r, -40, 40))}" r="${f1(between(r, 8, 18))}" fill="${p[2]}"/>`;
return s;
},
psyche(r, p) {
let s = `<rect x="${W / 2}" y="0" width="${W / 2}" height="${H}" fill="${p[3]}" opacity=".35"/>`;
const cy = between(r, 170, 290);
const rx = between(r, 60, 95), ry = between(r, 80, 130);
s += `<ellipse cx="${f1(W / 2 - rx * 0.45)}" cy="${f1(cy)}" rx="${f1(rx)}" ry="${f1(ry)}" fill="${p[2]}" opacity=".85"/>`;
s += `<ellipse cx="${f1(W / 2 + rx * 0.45)}" cy="${f1(cy)}" rx="${f1(rx)}" ry="${f1(ry)}" fill="none" stroke="${p[1]}" stroke-width="2.5"/>`;
s += `<line x1="${W / 2}" y1="40" x2="${W / 2}" y2="${H - 40}" stroke="${p[1]}" stroke-width="1" opacity=".6"/>`;
return s;
},
wellen(r, p) {
let s = '';
const n = 6 + Math.floor(r() * 5);
const amp = between(r, 6, 16), len = between(r, 60, 120);
for (let i = 0; i < n; i++) {
const y0 = 120 + i * (300 / n);
let d = `M0 ${f1(y0)}`;
for (let x = 0; x <= W; x += 10) d += ` L${x} ${f1(y0 + Math.sin((x / len) * Math.PI * 2 + i * 0.7) * amp)}`;
d += ` L${W} ${H} L0 ${H} Z`;
s += `<path d="${d}" fill="${i % 2 ? p[2] : p[3]}" opacity="${f1(0.18 + i * 0.07)}"/>`;
}
return s;
},
karte(r, p) {
let s = '';
const cx = between(r, 80, 220), cy = between(r, 160, 320);
for (let i = 1; i <= 9; i++) {
let d = '';
for (let k = 0; k <= 36; k++) {
const a = (k / 36) * Math.PI * 2;
const rad = i * 15 * (1 + 0.18 * Math.sin(a * 3 + i) + 0.1 * Math.cos(a * 5 + r()));
d += (k ? ' L' : 'M') + f1(cx + Math.cos(a) * rad) + ' ' + f1(cy + Math.sin(a) * rad * 1.15);
}
s += `<path d="${d} Z" fill="none" stroke="${i % 4 ? p[1] : p[2]}" stroke-width="${i % 4 ? 0.8 : 1.8}" opacity="${i % 4 ? 0.45 : 0.9}"/>`;
}
s += `<g stroke="${p[2]}" stroke-width="1.4"><line x1="${f1(cx)}" y1="${f1(cy - 30)}" x2="${f1(cx)}" y2="${f1(cy + 30)}"/><line x1="${f1(cx - 30)}" y1="${f1(cy)}" x2="${f1(cx + 30)}" y2="${f1(cy)}"/></g>`;
return s;
},
kosmos(r, p) {
let s = '';
for (let i = 0; i < 70; i++) s += `<circle cx="${f1(r() * W)}" cy="${f1(r() * H)}" r="${f1(between(r, 0.4, 1.5))}" fill="${p[1]}" opacity="${f1(between(r, 0.3, 0.9))}"/>`;
const cx = between(r, 90, 210), cy = between(r, 170, 290), pr = between(r, 30, 55);
for (let i = 1; i <= 3; i++) s += `<ellipse cx="${f1(cx)}" cy="${f1(cy)}" rx="${f1(pr + i * 28)}" ry="${f1((pr + i * 28) * 0.38)}" fill="none" stroke="${p[3]}" stroke-width="1" opacity=".6" transform="rotate(-18 ${f1(cx)} ${f1(cy)})"/>`;
s += `<circle cx="${f1(cx)}" cy="${f1(cy)}" r="${f1(pr)}" fill="${p[2]}"/>`;
s += `<circle cx="${f1(cx + pr * 0.35)}" cy="${f1(cy - pr * 0.2)}" r="${f1(pr)}" fill="${p[0]}" opacity=".35"/>`;
return s;
},
ornament(r, p) {
const cx = W / 2, cy = between(r, 190, 260), n = pick(r, [8, 10, 12, 16]);
let s = `<g transform="translate(${cx} ${f1(cy)})">`;
const len = between(r, 70, 105), wid = between(r, 18, 30);
for (let i = 0; i < n; i++) {
s += `<path d="M0 0 C ${f1(wid)} ${f1(-len * 0.4)} ${f1(wid * 0.5)} ${f1(-len * 0.8)} 0 ${f1(-len)} C ${f1(-wid * 0.5)} ${f1(-len * 0.8)} ${f1(-wid)} ${f1(-len * 0.4)} 0 0 Z" ` +
`fill="${i % 2 ? p[2] : p[3]}" opacity=".85" transform="rotate(${f1(i * 360 / n)})"/>`;
}
s += `<circle r="${f1(between(r, 12, 20))}" fill="${p[1]}"/><circle r="${f1(len + 12)}" fill="none" stroke="${p[2]}" stroke-width="1.5"/></g>`;
s += `<rect x="14" y="14" width="${W - 28}" height="${H - 28}" fill="none" stroke="${p[2]}" stroke-width="1.5" opacity=".8"/>`;
return s;
},
botanik(r, p) {
let s = '';
const n = 5 + Math.floor(r() * 4);
for (let i = 0; i < n; i++) {
const x = between(r, 40, 260), y = between(r, 150, 420), len = between(r, 60, 130), a = between(r, -50, 50);
s += `<g transform="translate(${f1(x)} ${f1(y)}) rotate(${f1(a)})">` +
`<path d="M0 0 C ${f1(len * 0.35)} ${f1(-len * 0.35)} ${f1(len * 0.3)} ${f1(-len * 0.8)} 0 ${f1(-len)} C ${f1(-len * 0.3)} ${f1(-len * 0.8)} ${f1(-len * 0.35)} ${f1(-len * 0.35)} 0 0 Z" fill="${i % 3 ? p[2] : p[3]}" opacity=".8"/>` +
`<line x1="0" y1="0" x2="0" y2="${f1(-len * 0.95)}" stroke="${p[0]}" stroke-width="1.2" opacity=".7"/></g>`;
}
return s;
},
berge(r, p) {
let s = `<circle cx="${f1(between(r, 60, 240))}" cy="${f1(between(r, 120, 190))}" r="${f1(between(r, 22, 38))}" fill="${p[3]}"/>`;
for (let i = 0; i < 4; i++) {
const base = 260 + i * 45;
let d = `M0 ${H} L0 ${base}`;
for (let x = 0; x <= W; x += 30) d += ` L${x} ${f1(base - between(r, 10, 90 - i * 15))}`;
d += ` L${W} ${base} L${W} ${H} Z`;
s += `<path d="${d}" fill="${i % 2 ? p[1] : p[2]}" opacity="${f1(0.35 + i * 0.18)}"/>`;
}
return s;
},
zellen(r, p) {
let s = '';
for (let i = 0; i < 16; i++) {
const x = between(r, 20, 280), y = between(r, 140, 430), rad = between(r, 12, 42);
s += `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(rad)}" fill="${i % 3 ? p[3] : p[2]}" opacity="${f1(between(r, 0.25, 0.75))}"/>`;
if (i % 4 === 0) s += `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(rad * 0.3)}" fill="${p[1]}" opacity=".7"/>`;
}
return s;
},
raster(r, p) {
if (r() < 0.45) { // or a skyline
let s = '';
let x = 10;
while (x < W - 10) {
const w = between(r, 18, 46), h = between(r, 60, 230), v = r();
s += `<rect x="${f1(x)}" y="${f1(H - 20 - h)}" width="${f1(w)}" height="${f1(h)}" fill="${v < 0.15 ? p[2] : v < 0.25 ? p[3] : p[1]}" opacity="${v < 0.25 ? 0.95 : f1(0.12 + v * 0.18)}"/>`;
for (let wy = H - 30 - h + 12; wy < H - 32; wy += 14) {
if (r() < 0.4) s += `<rect x="${f1(x + 5)}" y="${f1(wy)}" width="${f1(Math.max(3, w - 10))}" height="3" fill="${p[0]}" opacity=".5"/>`;
}
x += w + between(r, 2, 6);
}
return s;
}
let s = '';
const cols = 4 + Math.floor(r() * 3), rows = 5 + Math.floor(r() * 3), gap = 6;
const cw = (W - 40 - gap * (cols - 1)) / cols, rh = (H - 200 - gap * (rows - 1)) / rows;
for (let i = 0; i < cols; i++) for (let j = 0; j < rows; j++) {
const v = r();
const fill = v < 0.12 ? p[2] : v < 0.2 ? p[3] : p[1];
s += `<rect x="${f1(20 + i * (cw + gap))}" y="${f1(180 + j * (rh + gap))}" width="${f1(cw)}" height="${f1(rh)}" fill="${fill}" opacity="${v < 0.2 ? 0.95 : f1(0.08 + v * 0.12)}"/>`;
}
return s;
},
schatten(r, p) {
const x = between(r, 60, 240);
return `<rect x="${f1(x)}" y="0" width="${f1(between(r, 3, 8))}" height="${H}" fill="${p[2]}"/>` +
`<circle cx="${f1(x)}" cy="${f1(between(r, 200, 330))}" r="${f1(between(r, 50, 90))}" fill="${p[3]}" opacity=".35"/>`;
},
typo(r, p, book) {
const ch = (book.title || '?').replace(/^[^A-Za-zÄÖÜäöüß0-9]+/, '').charAt(0).toUpperCase() || '?';
return `<text x="${f1(between(r, 20, 60))}" y="${f1(between(r, 380, 430))}" font-family="${SERIF}" font-size="${f1(between(r, 240, 320))}" ` +
`fill="${p[2]}" opacity=".9">${esc(ch)}</text>` +
`<line x1="20" y1="${f1(between(r, 120, 160))}" x2="${W - 20}" y2="${f1(between(r, 120, 160))}" stroke="${p[1]}" stroke-width="1" opacity=".5"/>`;
},
bauhaus(r, p) {
let s = '';
for (let i = 0; i < 4; i++) {
const kind = Math.floor(r() * 3), x = between(r, 30, 270), y = between(r, 150, 410), size = between(r, 40, 110);
const fill = pick(r, [p[2], p[3], p[1]]);
if (kind === 0) s += `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(size / 2)}" fill="${fill}" opacity=".9"/>`;
else if (kind === 1) s += `<rect x="${f1(x - size / 2)}" y="${f1(y - size / 2)}" width="${f1(size)}" height="${f1(size)}" fill="${fill}" opacity=".9" transform="rotate(${f1(between(r, -20, 20))} ${f1(x)} ${f1(y)})"/>`;
else s += `<path d="M${f1(x)} ${f1(y - size / 2)} L${f1(x + size / 2)} ${f1(y + size / 2)} L${f1(x - size / 2)} ${f1(y + size / 2)} Z" fill="${fill}" opacity=".9"/>`;
}
return s;
},
};
// ---- the text -------------------------------------------------------------------------------------
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'}[c]));
}
// The title's lines, for a size that fits; a word too long is broken.
function lines(text, size, max) {
const per = Math.max(4, Math.floor(250 / (size * 0.56)));
const words = String(text).split(/\s+/).filter(Boolean);
const out = [];
let cur = '';
words.forEach(w => {
while (w.length > per) { if (cur) { out.push(cur); cur = ''; } out.push(w.slice(0, per - 1) + '‑'); w = w.slice(per - 1); }
if (!cur) cur = w;
else if ((cur + ' ' + w).length <= per) cur += ' ' + w;
else { out.push(cur); cur = w; }
});
if (cur) out.push(cur);
if (out.length > max) { out.length = max; out[max - 1] = out[max - 1].replace(/\s*\S*$/, '') + ' …'; }
return out;
}
// Only the main title ("Seeing Like a State: How …" → "Seeing Like a State").
function mainTitle(t) {
let s = String(t || '').split(/\s[-:–—•·\/]\s|:\s/)[0].trim();
// "Der Zauberberg. Erster Band" → "Der Zauberberg", but not at an initial ("George R. R. Martin").
const re = /(\S+)\.\s+(?=[A-ZÄÖÜ])/g;
let m;
while ((m = re.exec(s))) {
if (m[1].length >= 3) { s = s.slice(0, m.index + m[1].length); break; }
}
return s.length >= 3 ? s : String(t || '?');
}
function textBlock(r, th, p, book) {
const title = mainTitle(book.title);
const upper = th.upper;
const t = upper ? title.toUpperCase() : title;
const len = t.length;
const longest = t.split(/\s+/).reduce((n, w) => Math.max(n, w.length), 0);
// The longest word stays whole: the type gets smaller rather than the word broken.
const size = Math.max(15, Math.min(len <= 10 ? 40 : len <= 18 ? 34 : len <= 30 ? 28 : len <= 48 ? 23 : 19,
Math.floor(250 / (longest * 0.58))));
const ls = lines(t, size, 5);
const lh = size * 1.12;
const layout = pick(r, ['top', 'top', 'bottom', 'plate']);
const author = String(book.author || '').split(/[;,]/)[0].trim();
const blockH = ls.length * lh + (author ? 30 : 0) + 40;
let y0, s = '';
if (layout === 'top') {
y0 = 34;
s += `<rect x="0" y="0" width="${W}" height="${f1(blockH)}" fill="${p[0]}" opacity=".94"/>`;
} else if (layout === 'bottom') {
y0 = H - blockH + 20;
s += `<rect x="0" y="${f1(H - blockH)}" width="${W}" height="${f1(blockH)}" fill="${p[0]}" opacity=".94"/>`;
} else {
y0 = 70;
s += `<rect x="18" y="${f1(y0 - 34)}" width="${W - 36}" height="${f1(blockH)}" rx="3" fill="${p[0]}" opacity=".95"/>`;
}
const x = 26;
ls.forEach((l, i) => {
s += `<text x="${x}" y="${f1(y0 + size * 0.85 + i * lh)}" font-family="${th.font}" font-size="${size}" font-weight="${th.font === SERIF ? 400 : 700}" ` +
`fill="${p[1]}" letter-spacing="${upper ? 0.5 : 0}">${esc(l)}</text>`;
});
if (author) {
s += `<text x="${x}" y="${f1(y0 + ls.length * lh + 22)}" font-family="${SANS}" font-size="12" letter-spacing="2" fill="${p[2]}">${esc(author.toUpperCase())}</text>`;
}
return s;
}
// ---- the cover ---------------------------------------------------------------------------------------
function gray(hex) {
const n = parseInt(hex.slice(1), 16);
const y = Math.round(0.3 * (n >> 16) + 0.59 * ((n >> 8) & 255) + 0.11 * (n & 255));
const h = (y < 16 ? '0' : '') + y.toString(16);
return '#' + h + h + h;
}
function svg(book, opts) {
const th = theme(book);
const r = rng(hash(norm(book.title) + '|' + norm(book.author)));
let p = pick(r, th.palettes);
if (opts && opts.gray) p = p.map(gray);
const motif = (M[th.id] || M.bauhaus)(r, p, book);
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
`<rect width="${W}" height="${H}" fill="${p[0]}"/>${motif}${textBlock(r, th, p, book)}</svg>`;
}
const cache = new Map();
function url(book, opts) {
const key = (book.title || '') + '|' + (book.author || '') + '|' + (book.shelfTag || '') + '|' +
((book.folders || []).join(',')) + '|' + (opts && opts.gray ? 1 : 0);
if (!cache.has(key)) cache.set(key, 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg(book, opts)));
return cache.get(key);
}
window.DioraGenCover = {url, svg, theme: book => theme(book).id};
})();